@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,1331 @@
|
|
|
1
|
+
import { Effect, Option, Schema } from "effect"
|
|
2
|
+
import {
|
|
3
|
+
CommandRunner,
|
|
4
|
+
GtdUsageError,
|
|
5
|
+
Narrator,
|
|
6
|
+
GitService,
|
|
7
|
+
Host,
|
|
8
|
+
Workspace,
|
|
9
|
+
type GitOperations,
|
|
10
|
+
type HostOps,
|
|
11
|
+
HISTORY_REF,
|
|
12
|
+
readRetainedHistory,
|
|
13
|
+
restorability,
|
|
14
|
+
loopLogPath,
|
|
15
|
+
} from "../platform/index.js"
|
|
16
|
+
import {
|
|
17
|
+
WORKFLOW_MODULE,
|
|
18
|
+
ConfigDiscovery,
|
|
19
|
+
refuseLegacyRc,
|
|
20
|
+
ConfigService,
|
|
21
|
+
formatDiagnostic,
|
|
22
|
+
seededValidateCommand,
|
|
23
|
+
type PendingChange,
|
|
24
|
+
type StateMode,
|
|
25
|
+
type StateName,
|
|
26
|
+
type WorkflowDefinition,
|
|
27
|
+
} from "../workflow/index.js"
|
|
28
|
+
import type { Door } from "../flows/index.js"
|
|
29
|
+
import {
|
|
30
|
+
resolveSession,
|
|
31
|
+
beatDocument,
|
|
32
|
+
beatKindOf,
|
|
33
|
+
demandOf,
|
|
34
|
+
landFields,
|
|
35
|
+
noopText,
|
|
36
|
+
renderBeatJson,
|
|
37
|
+
renderBeatPlain,
|
|
38
|
+
renderLandJson,
|
|
39
|
+
renderLandPlain,
|
|
40
|
+
statusOf,
|
|
41
|
+
type BeatDocument,
|
|
42
|
+
type BeatKind,
|
|
43
|
+
type NextMatch,
|
|
44
|
+
type StatusChange,
|
|
45
|
+
selectPath,
|
|
46
|
+
} from "../wire/index.js"
|
|
47
|
+
import {
|
|
48
|
+
currentRest,
|
|
49
|
+
startRefusal,
|
|
50
|
+
currentRun,
|
|
51
|
+
renderRest,
|
|
52
|
+
restAt,
|
|
53
|
+
previewLanding,
|
|
54
|
+
noProcessUnderway,
|
|
55
|
+
restIsIdle,
|
|
56
|
+
reviewBaseFor,
|
|
57
|
+
snapshotFromRest,
|
|
58
|
+
stalledAt,
|
|
59
|
+
summaryFor,
|
|
60
|
+
summaryRun,
|
|
61
|
+
type RenderedRest,
|
|
62
|
+
} from "../edge/index.js"
|
|
63
|
+
import { planStart, planStep as planStepPure, type JudgeVerdict } from "../step/index.js"
|
|
64
|
+
import {
|
|
65
|
+
BUILT_IN_MODE_NAMES,
|
|
66
|
+
steeringFormatFor,
|
|
67
|
+
openThreads,
|
|
68
|
+
parseCodeThreads,
|
|
69
|
+
unansweredQuestions,
|
|
70
|
+
type SteeringFinding,
|
|
71
|
+
type SteeringFormat,
|
|
72
|
+
} from "../steering/index.js"
|
|
73
|
+
import {
|
|
74
|
+
resolveMode,
|
|
75
|
+
validateScriptFor,
|
|
76
|
+
deleteRef,
|
|
77
|
+
hardResetTo,
|
|
78
|
+
mixedResetTo,
|
|
79
|
+
ScriptSurface,
|
|
80
|
+
updateRef,
|
|
81
|
+
type RunnableScript,
|
|
82
|
+
combinedScript,
|
|
83
|
+
commandForFile,
|
|
84
|
+
emitScripts,
|
|
85
|
+
type EmitStep,
|
|
86
|
+
abandonedOutcome,
|
|
87
|
+
abandonNoopOutcome,
|
|
88
|
+
restoredOutcome,
|
|
89
|
+
} from "../emit/index.js"
|
|
90
|
+
import { CONFIG_SCAFFOLD, renderBriefing } from "../install/index.js"
|
|
91
|
+
import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `Edge.ts`'s `Rest` is module-private (`.gtd/packages/05-step-core.md`
|
|
95
|
+
* Commit 4) — every command here still needs its shape, so this derives the
|
|
96
|
+
* SAME type structurally off `currentRest`'s own success type rather than
|
|
97
|
+
* importing the name.
|
|
98
|
+
*/
|
|
99
|
+
type Rest = Effect.Effect.Success<typeof currentRest>
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A command-level failure that should exit like a CLI usage error (2), not a
|
|
103
|
+
* generic runtime failure (1): an unknown `--json=<path>` selector, or a
|
|
104
|
+
* `gtd judge answer` verdict that fails to decode — malformed JSON on stdin,
|
|
105
|
+
* or one naming a question id the pending judgment never asked. Both are
|
|
106
|
+
* caller-input errors per `docs/cli.md`'s exit-code table, not a refusal
|
|
107
|
+
* about the resolved rest's own state.
|
|
108
|
+
*/
|
|
109
|
+
export class SelectorUsageError extends Error {}
|
|
110
|
+
|
|
111
|
+
/** The `--json=<path>` select branch, shared by all three emitting commands so none drift on the unknown-selector message. `absent` writes nothing and lets the caller's success path continue. */
|
|
112
|
+
const writeSelection = (
|
|
113
|
+
out: ArtifactOut,
|
|
114
|
+
fields: unknown,
|
|
115
|
+
path: string,
|
|
116
|
+
): Effect.Effect<void, Error> =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const selection = selectPath(fields, path)
|
|
119
|
+
if (selection.kind === "value") {
|
|
120
|
+
out.write(`${selection.text}\n`)
|
|
121
|
+
} else if (selection.kind === "unknown") {
|
|
122
|
+
return yield* Effect.fail(
|
|
123
|
+
new SelectorUsageError(
|
|
124
|
+
`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
export type Command =
|
|
131
|
+
| { readonly kind: "lsp" }
|
|
132
|
+
| {
|
|
133
|
+
readonly kind: "ui"
|
|
134
|
+
// `host`/`port` stay optional/undefined-when-absent: a resolved bind address/port is
|
|
135
|
+
// `ui: { host?, port? }` config-file values merged in downstream
|
|
136
|
+
// (`src/ui/Server.ts`), which the argv parser never
|
|
137
|
+
// reads — an absent flag here must not shadow a configured value with
|
|
138
|
+
// a parser-invented default.
|
|
139
|
+
readonly host?: string
|
|
140
|
+
readonly port?: number
|
|
141
|
+
readonly selfSigned: boolean
|
|
142
|
+
readonly dev: boolean
|
|
143
|
+
}
|
|
144
|
+
| {
|
|
145
|
+
readonly kind: "land"
|
|
146
|
+
readonly cost?: number
|
|
147
|
+
readonly model?: string
|
|
148
|
+
}
|
|
149
|
+
| {
|
|
150
|
+
readonly kind: "start"
|
|
151
|
+
readonly actor: string
|
|
152
|
+
readonly workflow: string
|
|
153
|
+
readonly vars: Readonly<Record<string, string>>
|
|
154
|
+
readonly label: string
|
|
155
|
+
}
|
|
156
|
+
| { readonly kind: "door"; readonly name: string; readonly args: readonly string[] }
|
|
157
|
+
| { readonly kind: "doors" }
|
|
158
|
+
| { readonly kind: "abandon" }
|
|
159
|
+
| { readonly kind: "restore" }
|
|
160
|
+
| { readonly kind: "next" }
|
|
161
|
+
| { readonly kind: "validate" }
|
|
162
|
+
| {
|
|
163
|
+
readonly kind: "check"
|
|
164
|
+
/** Absent only for the bare `gtd check --open-threads` form. */
|
|
165
|
+
readonly mode?: string
|
|
166
|
+
readonly file?: string
|
|
167
|
+
readonly openQuestions?: boolean
|
|
168
|
+
readonly openThreads?: boolean
|
|
169
|
+
}
|
|
170
|
+
| { readonly kind: "uncheck"; readonly file: string }
|
|
171
|
+
| { readonly kind: "install" }
|
|
172
|
+
| { readonly kind: "loop" }
|
|
173
|
+
| { readonly kind: "summary" }
|
|
174
|
+
| { readonly kind: "base" }
|
|
175
|
+
| { readonly kind: "judge" }
|
|
176
|
+
| { readonly kind: "judgeAnswer" }
|
|
177
|
+
| {
|
|
178
|
+
readonly kind: "judgeRun"
|
|
179
|
+
readonly provider?: "fixed" | "jev" | "llm"
|
|
180
|
+
readonly answers?: string
|
|
181
|
+
readonly model?: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `--json`'s three shapes: absent, bare (the whole document), or `--json=<path>`
|
|
186
|
+
* (one reduced value). See `src/wire/Select.ts` for how `select` is later resolved.
|
|
187
|
+
*/
|
|
188
|
+
export type JsonMode =
|
|
189
|
+
| { readonly kind: "off" }
|
|
190
|
+
| { readonly kind: "document" }
|
|
191
|
+
| { readonly kind: "select"; readonly path: string }
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* What a command kind needs before it may run. `pure`/`removed` never reach
|
|
195
|
+
* `io.layers()` at all (they resolve to `output`/`usage` plans). `state`
|
|
196
|
+
* marks the six kinds sharing the repo-root guard and the at-least-one-commit
|
|
197
|
+
* guard — a repository with no commits has no HEAD to derive workflow state
|
|
198
|
+
* from.
|
|
199
|
+
*/
|
|
200
|
+
export type Needs = "pure" | "removed" | "none" | "fs" | "state"
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The all-or-nothing stdout buffer every `run*Command` handler writes
|
|
204
|
+
* through instead of `io.stdout` directly. `flush()` is called exactly once,
|
|
205
|
+
* by `runCli`, after the command's Effect succeeds; on any failure `flush()`
|
|
206
|
+
* is never reached, so the buffer is simply discarded and nothing reaches
|
|
207
|
+
* `io.stdout`. `ui` is the one handler that calls `flush()` itself,
|
|
208
|
+
* ahead of blocking on `Effect.never` — since it blocks forever, `runCli`'s
|
|
209
|
+
* own flush-on-success would otherwise never fire and its output would never
|
|
210
|
+
* reach stdout.
|
|
211
|
+
*/
|
|
212
|
+
export interface ArtifactOut {
|
|
213
|
+
readonly write: (chunk: string) => void
|
|
214
|
+
readonly flush: () => void
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type CommandRequirements =
|
|
218
|
+
| GitService
|
|
219
|
+
| ConfigService
|
|
220
|
+
| ConfigDiscovery
|
|
221
|
+
| Workspace
|
|
222
|
+
| Host
|
|
223
|
+
| Narrator
|
|
224
|
+
| CommandRunner
|
|
225
|
+
| ToolCommands
|
|
226
|
+
|
|
227
|
+
const selfGtd = (): SelfGtd => {
|
|
228
|
+
const script = process.argv[1]
|
|
229
|
+
return script === undefined ? ["gtd"] : [process.execPath, script]
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** `needs: "none"` skips the repo-root guard — the server is keyed on file name, not workflow state. */
|
|
233
|
+
const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
|
|
234
|
+
Effect.flatMap(ToolCommands, (tools) => tools.lsp(selfGtd()))
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Prints the briefing, emitted by the binary itself so it's always current.
|
|
238
|
+
* At a repository root no `gtd.config.ts` governs yet, it also writes one
|
|
239
|
+
* importing gtd's shipped workflows, left uncommitted.
|
|
240
|
+
* Anywhere else it writes nothing.
|
|
241
|
+
*/
|
|
242
|
+
const runInstallCommand = (
|
|
243
|
+
out: ArtifactOut,
|
|
244
|
+
): Effect.Effect<void, Error, GitService | Workspace | Host | ConfigDiscovery | Narrator> =>
|
|
245
|
+
Effect.gen(function* () {
|
|
246
|
+
const briefing = renderBriefing()
|
|
247
|
+
out.write(briefing.endsWith("\n") ? briefing : briefing + "\n")
|
|
248
|
+
const host = yield* Host
|
|
249
|
+
const topLevel = yield* Effect.either((yield* GitService).topLevel())
|
|
250
|
+
if (topLevel._tag === "Left") return
|
|
251
|
+
if ((yield* host.realPath(topLevel.right)) !== (yield* host.realPath(host.root))) return
|
|
252
|
+
if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
|
|
253
|
+
yield* (yield* Workspace).write(WORKFLOW_MODULE, CONFIG_SCAFFOLD)
|
|
254
|
+
yield* (yield* Narrator).warn(
|
|
255
|
+
`gtd install: wrote ${WORKFLOW_MODULE}, importing gtd's shipped workflows — commit it before your first \`gtd land\``,
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
const runLoopCommand = (): Effect.Effect<void, Error, ToolCommands> =>
|
|
260
|
+
Effect.flatMap(ToolCommands, (tools) => tools.loop(selfGtd()))
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* `planLanding`'s result — a preview of what `gtd land` would do, plus the
|
|
264
|
+
* combined `script` the driver runs to do it. `script` is
|
|
265
|
+
* `combinedScript(required, optional)`, computed once here so plain
|
|
266
|
+
* `gtd land`'s stdout and `--json`'s `script` field stay byte-identical.
|
|
267
|
+
*/
|
|
268
|
+
interface LandResult {
|
|
269
|
+
readonly state: StateName
|
|
270
|
+
readonly subject: string | null
|
|
271
|
+
readonly cost: number | null
|
|
272
|
+
readonly model: string | null
|
|
273
|
+
readonly script: string
|
|
274
|
+
/** True for the one terminal shape: a no-op at a `script` rest. */
|
|
275
|
+
readonly settled: boolean
|
|
276
|
+
readonly idle: boolean
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Exactly one trailing newline — duplicated from `Cli.ts`'s own
|
|
281
|
+
* `normalizeTrailingNewline` rather than imported, to avoid a circular value
|
|
282
|
+
* dependency (`Cli.ts` already depends on this module). `LandResult.script`
|
|
283
|
+
* must carry its own trailing newline because it's embedded verbatim inside
|
|
284
|
+
* the `--json` document, never re-normalized at its tail.
|
|
285
|
+
*/
|
|
286
|
+
const normalizeScriptNewline = (script: string): string =>
|
|
287
|
+
script.length === 0 ? script : `${script.replace(/\n+$/, "")}\n`
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `combinedScript`, narrowed to accept only an already-guarded
|
|
291
|
+
* `RunnableScript` for `required` — the one boundary `ScriptSurface`'s brand
|
|
292
|
+
* exists to protect: `gtd land`/`gtd --workflow`'s emitted script. Every OTHER
|
|
293
|
+
* `combinedScript` call (`abandon`/`restore`/steering validate, below) builds
|
|
294
|
+
* from `emitScripts` directly and keeps the unbranded signature — those
|
|
295
|
+
* paths land no guard-gated git write for a brand to protect.
|
|
296
|
+
*/
|
|
297
|
+
const landingScript = (required: RunnableScript): string => combinedScript(required, "")
|
|
298
|
+
|
|
299
|
+
// git's empty-tree object — recognizes a process starting at the repository's
|
|
300
|
+
// very first commit (no earlier commit to rewind to).
|
|
301
|
+
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* `gtd land`'s own flags, threaded as one bag rather than growing
|
|
305
|
+
* `planLanding`/`runLandCommand`'s positional list. `judge` is `gtd judge
|
|
306
|
+
* answer`'s own extra: `planLanding({ judge })` is the ONE landing path both
|
|
307
|
+
* commands share — `runJudgeAnswerCommand` supplies verdicts, plain
|
|
308
|
+
* `gtd land` never does.
|
|
309
|
+
*/
|
|
310
|
+
interface LandOptions {
|
|
311
|
+
readonly cost?: number
|
|
312
|
+
readonly model?: string
|
|
313
|
+
readonly judge?: readonly JudgeVerdict[]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Decides `gtd land`'s one transition without performing it. Refusals fail the Effect; a no-op returns `subject: null` and empty scripts. */
|
|
317
|
+
const planLanding = (
|
|
318
|
+
opts: LandOptions = {},
|
|
319
|
+
current?: Rest,
|
|
320
|
+
): Effect.Effect<LandResult, Error, CommandRequirements> =>
|
|
321
|
+
Effect.gen(function* () {
|
|
322
|
+
const rest = current ?? (yield* currentRest)
|
|
323
|
+
const snapshot = yield* snapshotFromRest(rest, opts.judge)
|
|
324
|
+
const outcome = planStepPure(snapshot, opts)
|
|
325
|
+
|
|
326
|
+
if (outcome.kind === "refusal") {
|
|
327
|
+
return yield* Effect.fail(new Error(outcome.message))
|
|
328
|
+
}
|
|
329
|
+
if (outcome.kind === "noop") {
|
|
330
|
+
const required = ScriptSurface.render([
|
|
331
|
+
{ kind: "outcome", outcome: { kind: "note", text: noopText(outcome.state) } },
|
|
332
|
+
])
|
|
333
|
+
return {
|
|
334
|
+
state: outcome.state,
|
|
335
|
+
subject: null,
|
|
336
|
+
cost: null,
|
|
337
|
+
model: null,
|
|
338
|
+
script: normalizeScriptNewline(landingScript(required)),
|
|
339
|
+
settled: outcome.settled,
|
|
340
|
+
idle: outcome.state === rest.def.initial,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const required = ScriptSurface.render(outcome.steps)
|
|
345
|
+
return {
|
|
346
|
+
state: outcome.to,
|
|
347
|
+
subject: outcome.subject,
|
|
348
|
+
cost: opts.cost ?? null,
|
|
349
|
+
model: opts.model ?? null,
|
|
350
|
+
script: normalizeScriptNewline(landingScript(required)),
|
|
351
|
+
settled: false,
|
|
352
|
+
idle: outcome.to === rest.def.initial,
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* `gtd summary`: print the prompt for an agent to write the process HEAD
|
|
358
|
+
* closes or sits inside its own closing message. Writes nothing — no git, no
|
|
359
|
+
* state transition, no file. Refuses (throws, mapped to the runtime-error
|
|
360
|
+
* exit code) when the workflow declares no `summary` prompt, or the resolved
|
|
361
|
+
* run has no commits to name.
|
|
362
|
+
*/
|
|
363
|
+
const runSummaryCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
|
|
364
|
+
Effect.gen(function* () {
|
|
365
|
+
const run = yield* summaryRun
|
|
366
|
+
const rendered = yield* summaryFor(run)
|
|
367
|
+
if (rendered === undefined) {
|
|
368
|
+
return yield* Effect.fail(
|
|
369
|
+
new Error(
|
|
370
|
+
`gtd summary: refused — either this workflow declares no "summary:" template, or ` +
|
|
371
|
+
`there is no finished/in-flight process to summarize at HEAD`,
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
out.write(rendered.endsWith("\n") ? rendered : rendered + "\n")
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
/** Prints the review anchor hash bare, so an external tool can be pointed at the range under review. Nothing is written. Refuses when the run has no commits, where the hash would name a range with no review. */
|
|
379
|
+
const runBaseCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
|
|
380
|
+
Effect.gen(function* () {
|
|
381
|
+
const rest = yield* currentRest
|
|
382
|
+
if (rest.run.trace.length === 0) {
|
|
383
|
+
return yield* Effect.fail(new Error("gtd base: refused — no process is underway at HEAD"))
|
|
384
|
+
}
|
|
385
|
+
const base = reviewBaseFor(rest)
|
|
386
|
+
out.write(`${base}\n`)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
/** A read-only peek at the pending judgment — the same document `gtd next --json` carries. Refuses when the rest is not a judge step. `--json`'s three shapes match `gtd next`/`gtd land`. */
|
|
390
|
+
const runJudgeCommand = (
|
|
391
|
+
json: JsonMode,
|
|
392
|
+
out: ArtifactOut,
|
|
393
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
394
|
+
Effect.gen(function* () {
|
|
395
|
+
const rest = yield* currentRest
|
|
396
|
+
const rendered = yield* renderRest(rest)
|
|
397
|
+
if (rendered.judge === undefined) {
|
|
398
|
+
return yield* Effect.fail(
|
|
399
|
+
new Error(`gtd judge: refused — the resolved rest ("${rest.state}") declares no "judge:"`),
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
const document = rendered.judge.endsWith("\n") ? rendered.judge : `${rendered.judge}\n`
|
|
403
|
+
if (json.kind === "select") {
|
|
404
|
+
const parsed = yield* Effect.try({
|
|
405
|
+
try: () => JSON.parse(rendered.judge!) as unknown,
|
|
406
|
+
catch: () =>
|
|
407
|
+
new SelectorUsageError(
|
|
408
|
+
'gtd judge: the resolved rest\'s "judge:" template did not render valid JSON',
|
|
409
|
+
),
|
|
410
|
+
})
|
|
411
|
+
const selection = selectPath(parsed, json.path)
|
|
412
|
+
if (selection.kind === "value") {
|
|
413
|
+
out.write(`${selection.text}\n`)
|
|
414
|
+
} else if (selection.kind === "unknown") {
|
|
415
|
+
return yield* Effect.fail(
|
|
416
|
+
new SelectorUsageError(
|
|
417
|
+
`gtd: unknown --json selector "${selection.path}" — see \`gtd --help\``,
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
out.write(document)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
/** `gtd judge`'s own rendered document shape — only the `id` of each question is read here, to build the verdict's own Effect Schema. */
|
|
427
|
+
interface JudgeQuestion {
|
|
428
|
+
readonly id: string
|
|
429
|
+
}
|
|
430
|
+
interface JudgeDocument {
|
|
431
|
+
readonly questions: readonly JudgeQuestion[]
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const parseJudgeDocument = (rendered: string): Effect.Effect<JudgeDocument, Error> =>
|
|
435
|
+
Effect.try({
|
|
436
|
+
try: () => JSON.parse(rendered) as JudgeDocument,
|
|
437
|
+
catch: () =>
|
|
438
|
+
new Error(
|
|
439
|
+
'gtd judge answer: the resolved rest\'s "judge:" template did not render valid JSON',
|
|
440
|
+
),
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The first stdin-consuming command outside `src/lsp/Lsp.ts` (which reads LSP's
|
|
445
|
+
* own length-prefixed frames off `process.stdin`, not a one-shot read) —
|
|
446
|
+
* reads stdin to completion and decodes it as UTF-8 text, no framing.
|
|
447
|
+
*/
|
|
448
|
+
const readStdin = (): Effect.Effect<string, Error> =>
|
|
449
|
+
Effect.tryPromise({
|
|
450
|
+
try: async () => {
|
|
451
|
+
const chunks: Buffer[] = []
|
|
452
|
+
for await (const chunk of process.stdin as AsyncIterable<Buffer>) {
|
|
453
|
+
chunks.push(chunk)
|
|
454
|
+
}
|
|
455
|
+
return Buffer.concat(chunks).toString("utf8")
|
|
456
|
+
},
|
|
457
|
+
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
/** Stdout is written once, after the answerer resolves — a rejection leaves it empty. */
|
|
461
|
+
const runJudgeRunCommand = (
|
|
462
|
+
command: Extract<Command, { kind: "judgeRun" }>,
|
|
463
|
+
out: ArtifactOut,
|
|
464
|
+
): Effect.Effect<void, Error, Host | Narrator | ConfigDiscovery | ToolCommands> =>
|
|
465
|
+
Effect.gen(function* () {
|
|
466
|
+
const host = yield* Host
|
|
467
|
+
const tools = yield* ToolCommands
|
|
468
|
+
yield* refuseLegacyRc
|
|
469
|
+
const input = yield* readStdin()
|
|
470
|
+
const verdict = yield* Effect.tryPromise({
|
|
471
|
+
try: () =>
|
|
472
|
+
tools.judge({
|
|
473
|
+
provider: command.provider,
|
|
474
|
+
answers: command.answers,
|
|
475
|
+
model: command.model,
|
|
476
|
+
cwd: host.scratchDir,
|
|
477
|
+
env: host.env,
|
|
478
|
+
input,
|
|
479
|
+
}),
|
|
480
|
+
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
|
481
|
+
})
|
|
482
|
+
out.write(verdict)
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
/** A verdict's own `answer` shape covers every `judge:` primitive (`noul` → boolean, `choice` → string, `score` → number) without gtd itself interpreting which one applies — that's a driver/judge-model concern, not this decode's. */
|
|
486
|
+
const VerdictAnswer = Schema.Union(Schema.String, Schema.Number, Schema.Boolean)
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* One `gtd judge answer` verdict entry per pending question, `{ id, answer,
|
|
490
|
+
* p }` — `id` constrained to the pending state's OWN question ids (never a
|
|
491
|
+
* bare string), so a verdict naming a question this rest never asked fails
|
|
492
|
+
* to decode rather than silently being ignored. `ids.length === 0` (a
|
|
493
|
+
* `judge:` that rendered no questions) constrains `id` to `Schema.Never`,
|
|
494
|
+
* accepting only the empty array — there is nothing to answer.
|
|
495
|
+
*/
|
|
496
|
+
const verdictSchemaFor = (ids: readonly string[]) =>
|
|
497
|
+
Schema.Array(
|
|
498
|
+
Schema.Struct({
|
|
499
|
+
id: ids.length > 0 ? Schema.Literal(...(ids as [string, ...string[]])) : Schema.Never,
|
|
500
|
+
answer: VerdictAnswer,
|
|
501
|
+
// Bounded [0, 1] — every OTHER bound in the routes: matching path fails
|
|
502
|
+
// CLOSED (a non-finite rendered minP/maxP never matches); an
|
|
503
|
+
// unvalidated p wouldn't: a driver bug or a miscalibrated model
|
|
504
|
+
// sending p > 1 would clear every minP a workflow declares,
|
|
505
|
+
// unconditionally forcing the judged route on a live gate.
|
|
506
|
+
p: Schema.Number.pipe(Schema.between(0, 1)),
|
|
507
|
+
}),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Decodes a verdict off stdin against the pending judgment's question ids,
|
|
512
|
+
* then lands through the SAME `planLanding` path `gtd land` uses, so the two
|
|
513
|
+
* can never drift on the script contract, `settled`/`idle`, or the
|
|
514
|
+
* `Gtd-Judge:` trailer. `--json` emits the same `LandFields`.
|
|
515
|
+
*/
|
|
516
|
+
const runJudgeAnswerCommand = (
|
|
517
|
+
json: JsonMode,
|
|
518
|
+
out: ArtifactOut,
|
|
519
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
520
|
+
Effect.gen(function* () {
|
|
521
|
+
const rest = yield* currentRest
|
|
522
|
+
const rendered = yield* renderRest(rest)
|
|
523
|
+
if (rendered.judge === undefined) {
|
|
524
|
+
return yield* Effect.fail(
|
|
525
|
+
new Error(
|
|
526
|
+
`gtd judge answer: refused — the resolved rest ("${rest.state}") declares no "judge:"`,
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
const document = yield* parseJudgeDocument(rendered.judge)
|
|
531
|
+
const ids = document.questions.map((q) => q.id)
|
|
532
|
+
|
|
533
|
+
const raw = yield* readStdin()
|
|
534
|
+
const parsed = yield* Effect.try({
|
|
535
|
+
try: () => JSON.parse(raw) as unknown,
|
|
536
|
+
catch: () => new SelectorUsageError("gtd judge answer: stdin is not valid JSON"),
|
|
537
|
+
})
|
|
538
|
+
const verdict = yield* Schema.decodeUnknown(verdictSchemaFor(ids))(parsed).pipe(
|
|
539
|
+
Effect.mapError(
|
|
540
|
+
(e) =>
|
|
541
|
+
new SelectorUsageError(
|
|
542
|
+
`gtd judge answer: the verdict on stdin does not match the pending questions — ${e.message}`,
|
|
543
|
+
),
|
|
544
|
+
),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
const result = yield* planLanding({ judge: verdict }, rest)
|
|
548
|
+
const built = landFields(result)
|
|
549
|
+
if (json.kind === "document") {
|
|
550
|
+
out.write(renderLandJson(built))
|
|
551
|
+
} else if (json.kind === "select") {
|
|
552
|
+
yield* writeSelection(out, built, json.path)
|
|
553
|
+
} else {
|
|
554
|
+
out.write(renderLandPlain(built, "judge answer"))
|
|
555
|
+
}
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Lands whatever the tree shows at the resolved rest. Plain output is prose
|
|
560
|
+
* and never the script; `--json` carries it, so that stays the machine path a
|
|
561
|
+
* driver pipes to `sh`. Exit code is uniformly `EXIT_OK` on success — whose
|
|
562
|
+
* turn is next lives in the following `gtd next --json`'s `kind`.
|
|
563
|
+
*/
|
|
564
|
+
const runLandCommand = (
|
|
565
|
+
opts: LandOptions,
|
|
566
|
+
json: JsonMode,
|
|
567
|
+
out: ArtifactOut,
|
|
568
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
569
|
+
Effect.gen(function* () {
|
|
570
|
+
const result = yield* planLanding(opts)
|
|
571
|
+
const built = landFields(result)
|
|
572
|
+
if (json.kind === "document") {
|
|
573
|
+
out.write(renderLandJson(built))
|
|
574
|
+
} else if (json.kind === "select") {
|
|
575
|
+
yield* writeSelection(out, built, json.path)
|
|
576
|
+
} else {
|
|
577
|
+
out.write(renderLandPlain(built))
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Starts a brand new process on `<workflow>`, always as `"human"`. Captures
|
|
583
|
+
* whatever the working tree carries at the start, like an ordinary land,
|
|
584
|
+
* rather than demanding a clean tree.
|
|
585
|
+
*
|
|
586
|
+
* An unknown name is a usage error. Refused when a process is already
|
|
587
|
+
* underway, the workflow reaches no step, a `--var` name is undeclared, or
|
|
588
|
+
* `base` renders to something that is not an ancestor of (and differs from)
|
|
589
|
+
* HEAD.
|
|
590
|
+
*/
|
|
591
|
+
const runStartCommand = (
|
|
592
|
+
actor: string,
|
|
593
|
+
workflow: string,
|
|
594
|
+
varOverrides: Record<string, string>,
|
|
595
|
+
out: ArtifactOut,
|
|
596
|
+
commandLabel: string,
|
|
597
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
598
|
+
Effect.gen(function* () {
|
|
599
|
+
const config = yield* (yield* ConfigService).load
|
|
600
|
+
const resolved = config.workflowNamed(workflow)
|
|
601
|
+
if (resolved === undefined) {
|
|
602
|
+
return yield* Effect.fail(
|
|
603
|
+
new GtdUsageError(
|
|
604
|
+
`gtd --workflow: unknown workflow "${workflow}" — startable: ${config.workflowNames.join(", ")}`,
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
const rest = yield* restAt(undefined, varOverrides)
|
|
609
|
+
const started = yield* startRefusal(rest, resolved, varOverrides)
|
|
610
|
+
const plan = yield* planStart(
|
|
611
|
+
{
|
|
612
|
+
state: rest.state,
|
|
613
|
+
idle: noProcessUnderway(rest),
|
|
614
|
+
},
|
|
615
|
+
actor,
|
|
616
|
+
{
|
|
617
|
+
workflow: resolved,
|
|
618
|
+
first: started,
|
|
619
|
+
commandLabel,
|
|
620
|
+
vars: varOverrides,
|
|
621
|
+
config: rest.config,
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
if (plan.kind === "refusal") {
|
|
625
|
+
return yield* Effect.fail(new Error(plan.message))
|
|
626
|
+
}
|
|
627
|
+
out.write(landingScript(ScriptSurface.render(plan.steps)))
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
const doorSynopsis = (name: string, door: Door): string =>
|
|
631
|
+
[
|
|
632
|
+
name,
|
|
633
|
+
...(door.args ?? []).map((arg) => (arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)),
|
|
634
|
+
].join(" ")
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
|
|
638
|
+
* process settings its `vars` derive from the positional args. A wrong name or
|
|
639
|
+
* arity is a usage error naming what would have worked.
|
|
640
|
+
*/
|
|
641
|
+
const runDoorCommand = (
|
|
642
|
+
name: string,
|
|
643
|
+
args: readonly string[],
|
|
644
|
+
out: ArtifactOut,
|
|
645
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
646
|
+
Effect.gen(function* () {
|
|
647
|
+
const config = yield* (yield* ConfigService).load
|
|
648
|
+
const door = config.doors.get(name)
|
|
649
|
+
if (door === undefined) {
|
|
650
|
+
const known = [...config.doors]
|
|
651
|
+
return yield* Effect.fail(
|
|
652
|
+
new GtdUsageError(
|
|
653
|
+
`gtd door: unknown door "${name}" — doors: ${known.map(([n, d]) => doorSynopsis(n, d)).join(", ")}`,
|
|
654
|
+
),
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
const declared = door.args ?? []
|
|
658
|
+
const required = declared.filter((arg) => arg.optional !== true).length
|
|
659
|
+
if (args.length < required || args.length > declared.length) {
|
|
660
|
+
return yield* Effect.fail(
|
|
661
|
+
new GtdUsageError(
|
|
662
|
+
`gtd door ${name}: ${args.length < required ? "missing" : "too many"} arguments — usage: gtd door ${doorSynopsis(name, door)}`,
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
const named: Record<string, string | undefined> = {}
|
|
667
|
+
declared.forEach((arg, i) => {
|
|
668
|
+
named[arg.name] = args[i]
|
|
669
|
+
})
|
|
670
|
+
const vars = yield* Effect.try({
|
|
671
|
+
try: () => door.vars?.(named) ?? {},
|
|
672
|
+
catch: (e) => new Error(`gtd door ${name}: ${e instanceof Error ? e.message : String(e)}`),
|
|
673
|
+
})
|
|
674
|
+
yield* runStartCommand("human", door.workflow, { ...vars }, out, `gtd door ${name}`)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
/** `gtd doors`: every door and its args, sorted by name — plain lines, or one JSON array. */
|
|
678
|
+
const runDoorsCommand = (
|
|
679
|
+
json: JsonMode,
|
|
680
|
+
out: ArtifactOut,
|
|
681
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
682
|
+
Effect.gen(function* () {
|
|
683
|
+
const config = yield* (yield* ConfigService).load
|
|
684
|
+
const doors = [...config.doors]
|
|
685
|
+
if (json.kind === "off") {
|
|
686
|
+
out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""))
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
const document = doors.map(([n, d]) => ({
|
|
690
|
+
name: n,
|
|
691
|
+
workflow: d.workflow,
|
|
692
|
+
args: (d.args ?? []).map((arg) => ({ name: arg.name, optional: arg.optional === true })),
|
|
693
|
+
}))
|
|
694
|
+
if (json.kind === "document") out.write(`${JSON.stringify(document)}\n`)
|
|
695
|
+
else yield* writeSelection(out, document, json.path)
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Ends the process underway without completing it. Nothing is discarded: a
|
|
700
|
+
* `git reset --mixed` to the process's start commit leaves every turn
|
|
701
|
+
* commit's content in the working tree.
|
|
702
|
+
*
|
|
703
|
+
* Reads state off the trace rather than `resolveRest`, which refuses when HEAD
|
|
704
|
+
* names a state the current workflow no longer declares — exactly the
|
|
705
|
+
* situation this command must still work in, and why the read-side checks
|
|
706
|
+
* stay direct `GitService` reads.
|
|
707
|
+
*
|
|
708
|
+
* Idempotent at the initial state. The one refusal is a process whose first
|
|
709
|
+
* commit is the repository's root commit: nothing earlier to rewind to.
|
|
710
|
+
*/
|
|
711
|
+
const runAbandonCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
|
|
712
|
+
Effect.gen(function* () {
|
|
713
|
+
const git = yield* GitService
|
|
714
|
+
const config = yield* (yield* ConfigService).load
|
|
715
|
+
const def = config.workflow
|
|
716
|
+
const initial = def.initial
|
|
717
|
+
const run = yield* currentRun
|
|
718
|
+
if (run.trace.length === 0) {
|
|
719
|
+
const required = emitScripts([
|
|
720
|
+
{ kind: "outcome", command: abandonNoopOutcome(initial) },
|
|
721
|
+
]).required
|
|
722
|
+
out.write(combinedScript(required, ""))
|
|
723
|
+
return
|
|
724
|
+
}
|
|
725
|
+
const restState = run.trace[run.trace.length - 1]!.state
|
|
726
|
+
|
|
727
|
+
if (run.startParentHash === EMPTY_TREE) {
|
|
728
|
+
return yield* Effect.fail(
|
|
729
|
+
new Error(
|
|
730
|
+
`gtd abandon: the process underway (resting at "${restState}") starts at the ` +
|
|
731
|
+
"repository's first commit — there is no earlier commit to rewind to",
|
|
732
|
+
),
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const tip = yield* git.resolveRef("HEAD")
|
|
737
|
+
|
|
738
|
+
const steps: EmitStep[] = [
|
|
739
|
+
{ kind: "gitWrite", command: updateRef(HISTORY_REF, tip) },
|
|
740
|
+
{ kind: "gitWrite", command: mixedResetTo(run.startParentHash) },
|
|
741
|
+
{ kind: "outcome", command: abandonedOutcome(restState, run.startParentHash, initial) },
|
|
742
|
+
]
|
|
743
|
+
const required = emitScripts(steps).required
|
|
744
|
+
out.write(combinedScript(required, ""))
|
|
745
|
+
})
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* `gtd restore`: hard-reset HEAD back to the tip `gtd abandon` retained.
|
|
749
|
+
*
|
|
750
|
+
* Guarded by `restorability` so it never discards work it didn't create:
|
|
751
|
+
* refuses on a dirty working tree, no retained history, or HEAD having
|
|
752
|
+
* advanced past the retained tip with commits that would be lost.
|
|
753
|
+
*/
|
|
754
|
+
const runRestoreCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
|
|
755
|
+
Effect.gen(function* () {
|
|
756
|
+
const git = yield* GitService
|
|
757
|
+
|
|
758
|
+
// Reading rest.changes off currentRest (not pendingChanges directly)
|
|
759
|
+
// means the renamed-state refusal wins over the dirty-tree message when
|
|
760
|
+
// both apply.
|
|
761
|
+
const before = yield* currentRest
|
|
762
|
+
if (before.changes.length > 0) {
|
|
763
|
+
return yield* Effect.fail(
|
|
764
|
+
new Error(
|
|
765
|
+
"gtd restore: refuses on a dirty working tree — commit, stash, or discard your changes first.",
|
|
766
|
+
),
|
|
767
|
+
)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const retained = yield* readRetainedHistory(git)
|
|
771
|
+
if (Option.isNone(retained)) {
|
|
772
|
+
return yield* Effect.fail(new Error("gtd restore: no retained history to restore."))
|
|
773
|
+
}
|
|
774
|
+
const tip = retained.value
|
|
775
|
+
|
|
776
|
+
const headHash = yield* git.resolveRef("HEAD")
|
|
777
|
+
const check = yield* restorability(git, headHash, tip)
|
|
778
|
+
if (!check.ok) {
|
|
779
|
+
return yield* Effect.fail(
|
|
780
|
+
new Error(
|
|
781
|
+
`gtd restore: ${check.reason} — HEAD ${headHash.slice(0, 7)} is ahead of the ` +
|
|
782
|
+
`retained tip ${tip.slice(0, 7)}.`,
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// A preview of the resulting state, resolved at tip directly since no reset has happened yet.
|
|
788
|
+
const after = yield* restAt(tip)
|
|
789
|
+
|
|
790
|
+
const steps: EmitStep[] = [
|
|
791
|
+
{ kind: "gitWrite", command: hardResetTo(tip) },
|
|
792
|
+
{ kind: "gitWrite", command: deleteRef(HISTORY_REF) },
|
|
793
|
+
{ kind: "outcome", command: restoredOutcome(tip, after.state) },
|
|
794
|
+
]
|
|
795
|
+
const required = emitScripts(steps).required
|
|
796
|
+
out.write(combinedScript(required, ""))
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* The mode's own validate command for `file`: a declared `validate:` command
|
|
801
|
+
* verbatim; a `"builtin"` validator or no mode at all names the leaf
|
|
802
|
+
* `gtd check <mode>` invocation instead, so there's always something concrete
|
|
803
|
+
* to name.
|
|
804
|
+
*/
|
|
805
|
+
const selfValidateCommand = (
|
|
806
|
+
def: WorkflowDefinition,
|
|
807
|
+
state: string,
|
|
808
|
+
mode: StateMode,
|
|
809
|
+
file: string,
|
|
810
|
+
): string => {
|
|
811
|
+
const resolved = resolveMode(def, state, mode)
|
|
812
|
+
const validate = resolved.kind === "resolved" ? resolved.validate : undefined
|
|
813
|
+
const command = validate?.kind === "command" ? validate.command : seededValidateCommand(mode)
|
|
814
|
+
return commandForFile(command, file)
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const emitsValidatablePrompt = (rendered: RenderedRest): boolean =>
|
|
818
|
+
rendered.kind === "prompt" && rendered.file !== undefined && rendered.mode !== undefined
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Shared by `gtd validate --json` and `gtd next --json`'s `validate` field so
|
|
822
|
+
* the two can't drift. `undefined` means no `file:`+`mode:` declared.
|
|
823
|
+
*
|
|
824
|
+
* The file's presence is checked inside the emitted SCRIPT, not here: it is
|
|
825
|
+
* only knowable after the turn, so a turn that legitimately wrote nothing
|
|
826
|
+
* exits 0 rather than burning a fix turn. The contradiction round-trip is
|
|
827
|
+
* emitted before that guard, so it still runs at the first-write beat.
|
|
828
|
+
*/
|
|
829
|
+
const resolveValidateScript = (
|
|
830
|
+
rest: Rest,
|
|
831
|
+
): Effect.Effect<
|
|
832
|
+
{ readonly file: string; readonly mode: StateMode; readonly script: string } | undefined,
|
|
833
|
+
Error,
|
|
834
|
+
Host
|
|
835
|
+
> =>
|
|
836
|
+
Effect.gen(function* () {
|
|
837
|
+
const file = rest.hints.file
|
|
838
|
+
const mode = rest.stepDef.mode
|
|
839
|
+
if (file === undefined || mode === undefined) return undefined
|
|
840
|
+
|
|
841
|
+
const resolved = resolveMode(rest.def, rest.state, mode)
|
|
842
|
+
if (resolved.kind === "unknown") {
|
|
843
|
+
return yield* Effect.fail(new Error(resolved.message))
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const { script } = yield* validateScriptFor(resolved, file)
|
|
847
|
+
return { file, mode, script }
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
/** The driver-facing `BeatKind` for a currently-resolved rest, the one computation `gatherBeatDocument` reads — so a driver's `kind` field can never drift from what it assembled. */
|
|
851
|
+
const restBeatKind = (rest: Rest): BeatKind =>
|
|
852
|
+
beatKindOf({
|
|
853
|
+
contentKind: rest.stepDef.kind,
|
|
854
|
+
dirty: rest.changes.length > 0,
|
|
855
|
+
stalled: stalledAt(rest),
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* `gtd next`: pure emitter of the resolved rest's beat, in two encodings —
|
|
860
|
+
* `--json` and plain (the default). No mutation: nothing is written,
|
|
861
|
+
* so a peek and a would-be dispatch are the same call. Exit code is
|
|
862
|
+
* `EXIT_OK` unconditionally — whose turn is next lives in the beat
|
|
863
|
+
* document's own `kind` field, never the exit code.
|
|
864
|
+
*/
|
|
865
|
+
const runNextCommand = (
|
|
866
|
+
json: JsonMode,
|
|
867
|
+
out: ArtifactOut,
|
|
868
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
869
|
+
Effect.gen(function* () {
|
|
870
|
+
const rest = yield* currentRest
|
|
871
|
+
const rendered = yield* renderRest(rest)
|
|
872
|
+
const fields = yield* gatherBeatDocument(rest, rendered)
|
|
873
|
+
const narrator = yield* Narrator
|
|
874
|
+
for (const change of fields.changes) {
|
|
875
|
+
yield* narrator.narrate(`pending: ${change.status} ${change.path}`)
|
|
876
|
+
}
|
|
877
|
+
if (json.kind === "document") {
|
|
878
|
+
out.write(renderBeatJson(fields))
|
|
879
|
+
} else if (json.kind === "select") {
|
|
880
|
+
yield* writeSelection(out, fields, json.path)
|
|
881
|
+
} else {
|
|
882
|
+
const validateCommand = emitsValidatablePrompt(rendered)
|
|
883
|
+
? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
|
|
884
|
+
: undefined
|
|
885
|
+
out.write(renderBeatPlain(fields, validateCommand))
|
|
886
|
+
}
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
/** Emits the format-then-validate script for the driver to run; reads no file and executes nothing itself. The verdict lives in that script's future exit code, which prints the complete fix prompt rather than bare findings. */
|
|
890
|
+
const runValidateCommand = (out: ArtifactOut): Effect.Effect<void, Error, CommandRequirements> =>
|
|
891
|
+
Effect.gen(function* () {
|
|
892
|
+
const rest = yield* currentRest
|
|
893
|
+
const resolved = yield* resolveValidateScript(rest)
|
|
894
|
+
out.write(
|
|
895
|
+
resolved !== undefined && resolved.script.length > 0
|
|
896
|
+
? `${resolved.script}\n`
|
|
897
|
+
: `nothing to validate at "${rest.state}"\n`,
|
|
898
|
+
)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
/** `file:line:col: message` — the shape editors and grep already jump on. Stored 0-based, printed 1-based. A finding with a `line` but no `range`, or none at all, degrades to the shorter forms. */
|
|
902
|
+
export const formatFinding = (file: string, finding: SteeringFinding): string => {
|
|
903
|
+
if (finding.line === undefined) return finding.message
|
|
904
|
+
const col = finding.range !== undefined ? `:${finding.range.start.character + 1}` : ""
|
|
905
|
+
return `${file}:${finding.line + 1}${col}: ${finding.message}`
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Runs `<mode>`'s parser over `<file>`, exiting non-zero on any finding.
|
|
910
|
+
* Resolves no workflow state and reads no config — this is what a seeded
|
|
911
|
+
* `validate:` command invokes as a leaf step, and it formats nothing.
|
|
912
|
+
*
|
|
913
|
+
* An absent file exits 0, like `gtd validate`. Findings ride the failing
|
|
914
|
+
* Effect's message, so the shared refusal path reports them on stderr.
|
|
915
|
+
*/
|
|
916
|
+
const runCheckCommand = (
|
|
917
|
+
mode: string,
|
|
918
|
+
file: string,
|
|
919
|
+
openQuestions: boolean,
|
|
920
|
+
openThreadsFlag: boolean,
|
|
921
|
+
): Effect.Effect<void, Error, Workspace> =>
|
|
922
|
+
Effect.gen(function* () {
|
|
923
|
+
const format = steeringFormatFor(mode)
|
|
924
|
+
if (format === undefined) {
|
|
925
|
+
return yield* Effect.fail(
|
|
926
|
+
new Error(
|
|
927
|
+
`gtd check: unknown mode "${mode}" — known modes: ${BUILT_IN_MODE_NAMES.join(", ")}`,
|
|
928
|
+
),
|
|
929
|
+
)
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (openThreadsFlag) {
|
|
933
|
+
if (openQuestions) {
|
|
934
|
+
return yield* Effect.fail(
|
|
935
|
+
new Error("gtd check: --open-threads and --open-questions are mutually exclusive"),
|
|
936
|
+
)
|
|
937
|
+
}
|
|
938
|
+
if (format.openThreads === undefined) {
|
|
939
|
+
const accepted = BUILT_IN_MODE_NAMES.filter(
|
|
940
|
+
(name) => steeringFormatFor(name)?.openThreads !== undefined,
|
|
941
|
+
)
|
|
942
|
+
return yield* Effect.fail(
|
|
943
|
+
new Error(
|
|
944
|
+
`gtd check: --open-threads only applies to modes ${accepted.join(", ")} — got "${mode}"`,
|
|
945
|
+
),
|
|
946
|
+
)
|
|
947
|
+
}
|
|
948
|
+
return yield* runOpenThreadsCheckCommand(format, file)
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
if (openQuestions) {
|
|
952
|
+
if (mode !== "qa") {
|
|
953
|
+
return yield* Effect.fail(
|
|
954
|
+
new Error(`gtd check: --open-questions only applies to mode "qa" — got "${mode}"`),
|
|
955
|
+
)
|
|
956
|
+
}
|
|
957
|
+
return yield* runOpenQuestionsCheckCommand(format, file)
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const workspace = yield* Workspace
|
|
961
|
+
const content = workspace.atPath(file)
|
|
962
|
+
const errors =
|
|
963
|
+
content !== undefined
|
|
964
|
+
? format.validate(content).map((finding) => formatFinding(file, finding))
|
|
965
|
+
: []
|
|
966
|
+
|
|
967
|
+
if (errors.length === 0) return
|
|
968
|
+
|
|
969
|
+
return yield* Effect.fail(
|
|
970
|
+
new Error(
|
|
971
|
+
`gtd check: ${file} is not valid under mode "${mode}" (${errors.length} finding(s)):\n` +
|
|
972
|
+
errors.join("\n"),
|
|
973
|
+
),
|
|
974
|
+
)
|
|
975
|
+
})
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* The bare `gtd check --open-threads`: code-comment threads in the files the
|
|
979
|
+
* current process changed. The one `check` form that reads process state; no
|
|
980
|
+
* process underway is silence, not an error.
|
|
981
|
+
*/
|
|
982
|
+
const runCodeThreadsCheckCommand = (): Effect.Effect<void, Error, CommandRequirements> =>
|
|
983
|
+
Effect.gen(function* () {
|
|
984
|
+
const rest = yield* currentRest
|
|
985
|
+
if (rest.run.trace.length === 0) return
|
|
986
|
+
const git = yield* GitService
|
|
987
|
+
const workspace = yield* Workspace
|
|
988
|
+
const changed = yield* git.changedPaths(rest.run.diffBase)
|
|
989
|
+
const lines = changed
|
|
990
|
+
.filter((c) => c.status !== "D")
|
|
991
|
+
.flatMap(({ path }) => {
|
|
992
|
+
const { threads, findings } = parseCodeThreads(path, workspace.atPath(path) ?? "")
|
|
993
|
+
return [
|
|
994
|
+
...threads
|
|
995
|
+
.filter((t) => t.waitingOn === "human")
|
|
996
|
+
.map((t) => `${path}:${t.line + 1}: ${t.entries[0]!.text}`),
|
|
997
|
+
...findings.map((f) => `${path}:${(f.line ?? 0) + 1}: ${f.message}`),
|
|
998
|
+
]
|
|
999
|
+
})
|
|
1000
|
+
if (lines.length === 0) return
|
|
1001
|
+
return yield* Effect.fail(new Error(lines.join("\n")))
|
|
1002
|
+
})
|
|
1003
|
+
|
|
1004
|
+
/** Prints one `file:line: [^name]: <first H entry>` line per open thread; a missing file fails, like `--open-questions`. */
|
|
1005
|
+
const runOpenThreadsCheckCommand = (
|
|
1006
|
+
format: SteeringFormat,
|
|
1007
|
+
file: string,
|
|
1008
|
+
): Effect.Effect<void, Error, Workspace> =>
|
|
1009
|
+
Effect.gen(function* () {
|
|
1010
|
+
const workspace = yield* Workspace
|
|
1011
|
+
const content = workspace.atPath(file)
|
|
1012
|
+
if (content === undefined) {
|
|
1013
|
+
return yield* Effect.fail(new Error(`gtd check: ${file} does not exist`))
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const errors = openThreads(format, content).map(
|
|
1017
|
+
(t) => `${file}:${t.line + 1}: [^${t.name}]: ${t.firstMe}`,
|
|
1018
|
+
)
|
|
1019
|
+
if (errors.length === 0) return
|
|
1020
|
+
|
|
1021
|
+
return yield* Effect.fail(
|
|
1022
|
+
new Error(`gtd check: ${errors.length} open thread(s) in ${file}:\n` + errors.join("\n")),
|
|
1023
|
+
)
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Runs the SAME `unansweredQuestions` predicate a flow's `openQuestions()`
|
|
1028
|
+
* reads, so the gate script and the flow's own check stay in sync. A missing
|
|
1029
|
+
* file is a non-zero exit here, unlike the structural path above.
|
|
1030
|
+
*/
|
|
1031
|
+
const runOpenQuestionsCheckCommand = (
|
|
1032
|
+
format: SteeringFormat,
|
|
1033
|
+
file: string,
|
|
1034
|
+
): Effect.Effect<void, Error, Workspace> =>
|
|
1035
|
+
Effect.gen(function* () {
|
|
1036
|
+
const workspace = yield* Workspace
|
|
1037
|
+
const content = workspace.atPath(file)
|
|
1038
|
+
if (content === undefined) {
|
|
1039
|
+
return yield* Effect.fail(new Error(`gtd check: ${file} does not exist`))
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const errors = unansweredQuestions(format, content).map(
|
|
1043
|
+
(q) => `${file}:${q.headingLine + 1}: ${q.question}`,
|
|
1044
|
+
)
|
|
1045
|
+
if (errors.length === 0) return
|
|
1046
|
+
|
|
1047
|
+
return yield* Effect.fail(
|
|
1048
|
+
new Error(
|
|
1049
|
+
`gtd check: ${errors.length} open question(s) unanswered in ${file}:\n` + errors.join("\n"),
|
|
1050
|
+
),
|
|
1051
|
+
)
|
|
1052
|
+
})
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* Clears the `review` format's ticks in `<file>`, writing back only when the
|
|
1056
|
+
* bytes changed so an untouched file's mtime never moves. Standalone: no
|
|
1057
|
+
* workflow state, no config.
|
|
1058
|
+
*
|
|
1059
|
+
* Takes no `<mode>` argument and must never grow one — this means review
|
|
1060
|
+
* pointers and nothing else, and must never touch `qa`'s answer boxes.
|
|
1061
|
+
*/
|
|
1062
|
+
const runUncheckCommand = (file: string): Effect.Effect<void, Error, Workspace> =>
|
|
1063
|
+
Effect.gen(function* () {
|
|
1064
|
+
// `gtd uncheck` means review-mode file pointers, hardcoded — never the
|
|
1065
|
+
// qa format, whose answer boxes it must never touch.
|
|
1066
|
+
const format = steeringFormatFor("review")
|
|
1067
|
+
if (format === undefined) return
|
|
1068
|
+
|
|
1069
|
+
const workspace = yield* Workspace
|
|
1070
|
+
const content = workspace.atPath(file)
|
|
1071
|
+
if (content === undefined) return
|
|
1072
|
+
|
|
1073
|
+
const cleared = format.clearTicks(content)
|
|
1074
|
+
if (cleared === content) return
|
|
1075
|
+
|
|
1076
|
+
yield* workspace.writeAtPath(file, cleared)
|
|
1077
|
+
})
|
|
1078
|
+
|
|
1079
|
+
/** The pending changes, as the beat reports them. */
|
|
1080
|
+
const computeStatusChanges = (changes: readonly PendingChange[]): readonly StatusChange[] =>
|
|
1081
|
+
changes.map((change) => ({ status: change.status, path: change.path }))
|
|
1082
|
+
|
|
1083
|
+
/** The step the pending change would land the process at. */
|
|
1084
|
+
const computeNextMatch = (rest: Rest): Effect.Effect<NextMatch | null> =>
|
|
1085
|
+
Effect.map(
|
|
1086
|
+
rest.changes.length === 0 ? Effect.succeed(undefined) : previewLanding(rest),
|
|
1087
|
+
(target) => (target === undefined ? null : { target }),
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
/** Everything one beat needs beyond the resolved rest itself, gathered once so plain/`--json` can never describe different rests for the same beat — built as `wire`'s `Demand` (what to do) and `BeatStatus` (what no driver branches on), then flattened by `beatDocument` into the one wire-shaped object plain/`--json` both render from. */
|
|
1091
|
+
const gatherBeatDocument = (
|
|
1092
|
+
rest: Rest,
|
|
1093
|
+
rendered: RenderedRest,
|
|
1094
|
+
): Effect.Effect<BeatDocument, Error, CommandRequirements> =>
|
|
1095
|
+
Effect.gen(function* () {
|
|
1096
|
+
const kind = restBeatKind(rest)
|
|
1097
|
+
const session =
|
|
1098
|
+
kind === "prompt" ? resolveSession(rendered.memory, rendered.memoryResumed) : undefined
|
|
1099
|
+
const resolvedValidate =
|
|
1100
|
+
kind === "prompt"
|
|
1101
|
+
? yield* resolveValidateScript(rest).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
|
|
1102
|
+
: undefined
|
|
1103
|
+
const validate =
|
|
1104
|
+
resolvedValidate !== undefined && resolvedValidate.script.length > 0
|
|
1105
|
+
? resolvedValidate.script
|
|
1106
|
+
: undefined
|
|
1107
|
+
const log = yield* loopLogPath
|
|
1108
|
+
const demand = demandOf({
|
|
1109
|
+
rendered,
|
|
1110
|
+
kind,
|
|
1111
|
+
...(session !== undefined
|
|
1112
|
+
? { session: { id: session.sessionId, resume: session.resume } }
|
|
1113
|
+
: {}),
|
|
1114
|
+
...(validate !== undefined ? { validate } : {}),
|
|
1115
|
+
})
|
|
1116
|
+
const status = statusOf({
|
|
1117
|
+
rendered,
|
|
1118
|
+
idle: restIsIdle(rest),
|
|
1119
|
+
initial: noProcessUnderway(rest),
|
|
1120
|
+
workflow: rest.workflowName,
|
|
1121
|
+
log,
|
|
1122
|
+
changes: computeStatusChanges(rest.changes),
|
|
1123
|
+
next: yield* computeNextMatch(rest),
|
|
1124
|
+
cost: rest.cost.total,
|
|
1125
|
+
costByModel: rest.cost.byModel,
|
|
1126
|
+
})
|
|
1127
|
+
return beatDocument(demand, status)
|
|
1128
|
+
})
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* `gtd ui`: loads `ui:` config and hands it, alongside the parsed flags, to
|
|
1132
|
+
* the ui tool. `needs: "state"` (see `needsOf` above) means this shares the
|
|
1133
|
+
* repo-root/at-least-one-commit guard with every other workflow-state
|
|
1134
|
+
* command — `gtd ui` operates on the invoking directory's own worktree,
|
|
1135
|
+
* never a configured list of roots.
|
|
1136
|
+
*/
|
|
1137
|
+
const runUiCliCommand = (
|
|
1138
|
+
command: Extract<Command, { kind: "ui" }>,
|
|
1139
|
+
out: ArtifactOut,
|
|
1140
|
+
): Effect.Effect<void, Error, CommandRequirements> =>
|
|
1141
|
+
Effect.gen(function* () {
|
|
1142
|
+
const config = yield* (yield* ConfigService).load
|
|
1143
|
+
const { root } = yield* Host
|
|
1144
|
+
yield* (yield* ToolCommands).ui(
|
|
1145
|
+
{
|
|
1146
|
+
flags: {
|
|
1147
|
+
...(command.host !== undefined ? { host: command.host } : {}),
|
|
1148
|
+
...(command.port !== undefined ? { port: command.port } : {}),
|
|
1149
|
+
selfSigned: command.selfSigned,
|
|
1150
|
+
dev: command.dev,
|
|
1151
|
+
},
|
|
1152
|
+
config: config.ui,
|
|
1153
|
+
root,
|
|
1154
|
+
},
|
|
1155
|
+
out,
|
|
1156
|
+
)
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Everything gtd derives is resolved against the process cwd, so running
|
|
1161
|
+
* from anywhere but the repository root would silently mis-derive state.
|
|
1162
|
+
* Refuses with a clear error instead. Real paths are compared so symlinked
|
|
1163
|
+
* cwds (e.g. macOS /tmp → /private/tmp) match.
|
|
1164
|
+
*/
|
|
1165
|
+
const assertRunningFromRepoRoot = (git: GitOperations, host: HostOps): Effect.Effect<void, Error> =>
|
|
1166
|
+
Effect.gen(function* () {
|
|
1167
|
+
const topLevel = yield* git.topLevel()
|
|
1168
|
+
const topReal = yield* host.realPath(topLevel)
|
|
1169
|
+
const cwdReal = yield* host.realPath(host.root)
|
|
1170
|
+
if (topReal !== cwdReal) {
|
|
1171
|
+
return yield* Effect.fail(
|
|
1172
|
+
new Error(
|
|
1173
|
+
`gtd must be run from the repository root (${topLevel}); ` +
|
|
1174
|
+
`the current directory is ${host.root}`,
|
|
1175
|
+
),
|
|
1176
|
+
)
|
|
1177
|
+
}
|
|
1178
|
+
})
|
|
1179
|
+
|
|
1180
|
+
/** A command that derives workflow state needs at least one commit too — a commitless repository has no HEAD to resolve a `Rest` at. */
|
|
1181
|
+
const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Error> =>
|
|
1182
|
+
Effect.gen(function* () {
|
|
1183
|
+
const hasCommits = yield* git.hasCommits()
|
|
1184
|
+
if (!hasCommits) {
|
|
1185
|
+
return yield* Effect.fail(
|
|
1186
|
+
new Error(
|
|
1187
|
+
"gtd requires a repository with at least one commit — make an initial commit, then run gtd again",
|
|
1188
|
+
),
|
|
1189
|
+
)
|
|
1190
|
+
}
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* What a command kind needs before it may run. Defined here, not in
|
|
1195
|
+
* `Cli.ts`, to avoid a circular value dependency (`Cli.ts` already depends
|
|
1196
|
+
* on this module for `runCommand`). `"state"` means the kind derives
|
|
1197
|
+
* workflow state, so it needs a repository root and at least one commit —
|
|
1198
|
+
* both guards run in `runCommand` before dispatch.
|
|
1199
|
+
*/
|
|
1200
|
+
export const needsOf = (kind: Command["kind"]): Needs => {
|
|
1201
|
+
switch (kind) {
|
|
1202
|
+
case "lsp":
|
|
1203
|
+
case "install":
|
|
1204
|
+
case "loop":
|
|
1205
|
+
case "judgeRun":
|
|
1206
|
+
return "none"
|
|
1207
|
+
case "check":
|
|
1208
|
+
case "uncheck":
|
|
1209
|
+
return "fs"
|
|
1210
|
+
default:
|
|
1211
|
+
return "state"
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** The kinds that never touch the repo-root guard — pinned so a new standalone kind can't be added silently. */
|
|
1216
|
+
export const standaloneKinds = (): readonly Command["kind"][] => [
|
|
1217
|
+
"lsp",
|
|
1218
|
+
"check",
|
|
1219
|
+
"uncheck",
|
|
1220
|
+
"install",
|
|
1221
|
+
"loop",
|
|
1222
|
+
"judgeRun",
|
|
1223
|
+
]
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* Dispatches to the named `run*Command` handler for every `Command.kind` —
|
|
1227
|
+
* the counterpart of `Cli.ts`'s `parseArgv`, which has already validated
|
|
1228
|
+
* every field a handler receives. A command choosing its own exit code is
|
|
1229
|
+
* unrepresentable: every branch returns `Effect<void>`, and `Cli.ts`'s
|
|
1230
|
+
* `runCli` supplies `EXIT_OK` once, after this Effect succeeds.
|
|
1231
|
+
*/
|
|
1232
|
+
// fallow-ignore-next-line complexity
|
|
1233
|
+
const dispatchVoidCommand = (
|
|
1234
|
+
command: Command,
|
|
1235
|
+
json: JsonMode,
|
|
1236
|
+
out: ArtifactOut,
|
|
1237
|
+
): Effect.Effect<void, Error, CommandRequirements> => {
|
|
1238
|
+
switch (command.kind) {
|
|
1239
|
+
case "lsp":
|
|
1240
|
+
return runLspCommand()
|
|
1241
|
+
case "ui":
|
|
1242
|
+
return runUiCliCommand(command, out)
|
|
1243
|
+
case "land":
|
|
1244
|
+
return runLandCommand(
|
|
1245
|
+
{
|
|
1246
|
+
...(command.cost !== undefined ? { cost: command.cost } : {}),
|
|
1247
|
+
...(command.model !== undefined ? { model: command.model } : {}),
|
|
1248
|
+
},
|
|
1249
|
+
json,
|
|
1250
|
+
out,
|
|
1251
|
+
)
|
|
1252
|
+
case "start":
|
|
1253
|
+
return runStartCommand(command.actor, command.workflow, command.vars, out, command.label)
|
|
1254
|
+
case "door":
|
|
1255
|
+
return runDoorCommand(command.name, command.args, out)
|
|
1256
|
+
case "doors":
|
|
1257
|
+
return runDoorsCommand(json, out)
|
|
1258
|
+
case "abandon":
|
|
1259
|
+
return runAbandonCommand(out)
|
|
1260
|
+
case "restore":
|
|
1261
|
+
return runRestoreCommand(out)
|
|
1262
|
+
case "next":
|
|
1263
|
+
return runNextCommand(json, out)
|
|
1264
|
+
case "validate":
|
|
1265
|
+
return runValidateCommand(out)
|
|
1266
|
+
case "check":
|
|
1267
|
+
if (command.mode === undefined || command.file === undefined) {
|
|
1268
|
+
return runCodeThreadsCheckCommand()
|
|
1269
|
+
}
|
|
1270
|
+
return runCheckCommand(
|
|
1271
|
+
command.mode,
|
|
1272
|
+
command.file,
|
|
1273
|
+
command.openQuestions ?? false,
|
|
1274
|
+
command.openThreads ?? false,
|
|
1275
|
+
)
|
|
1276
|
+
case "uncheck":
|
|
1277
|
+
return runUncheckCommand(command.file)
|
|
1278
|
+
case "install":
|
|
1279
|
+
return runInstallCommand(out)
|
|
1280
|
+
case "loop":
|
|
1281
|
+
return runLoopCommand()
|
|
1282
|
+
case "summary":
|
|
1283
|
+
return runSummaryCommand(out)
|
|
1284
|
+
case "base":
|
|
1285
|
+
return runBaseCommand(out)
|
|
1286
|
+
case "judge":
|
|
1287
|
+
return runJudgeCommand(json, out)
|
|
1288
|
+
case "judgeAnswer":
|
|
1289
|
+
return runJudgeAnswerCommand(json, out)
|
|
1290
|
+
case "judgeRun":
|
|
1291
|
+
return runJudgeRunCommand(command, out)
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* The one entry point `Cli.ts`'s `runCli` calls for a resolved `Command`:
|
|
1297
|
+
* dispatches to the matching `run*Command` handler, wrapped in the
|
|
1298
|
+
* repo-root-and-commit guard exactly when `needsOf(command.kind) === "state"`
|
|
1299
|
+
* (the `standaloneKinds` run bare). Both guard checks run before `dispatch`,
|
|
1300
|
+
* so a refusal writes nothing and emits no script by construction.
|
|
1301
|
+
*/
|
|
1302
|
+
export const runCommand = (
|
|
1303
|
+
command: Command,
|
|
1304
|
+
json: JsonMode,
|
|
1305
|
+
out: ArtifactOut,
|
|
1306
|
+
): Effect.Effect<void, Error, CommandRequirements> => {
|
|
1307
|
+
const dispatch = dispatchVoidCommand(command, json, out)
|
|
1308
|
+
if (needsOf(command.kind) !== "state") return dispatch
|
|
1309
|
+
return Effect.gen(function* () {
|
|
1310
|
+
const git = yield* GitService
|
|
1311
|
+
const host = yield* Host
|
|
1312
|
+
yield* assertRunningFromRepoRoot(git, host)
|
|
1313
|
+
yield* assertRepositoryHasCommits(git)
|
|
1314
|
+
// One extra load here, own to this block, just to read `.warnings` ahead
|
|
1315
|
+
// of `dispatch`'s own (unrelated) load(s) — `ConfigService.load` isn't
|
|
1316
|
+
// memoized, and `load` (`src/workflow/load.ts`) narrates a "config: module ..."
|
|
1317
|
+
// line on EVERY call, so a second bare load under --verbose
|
|
1318
|
+
// would double that output. Silence narration on this one call only (a
|
|
1319
|
+
// scoped Narrator override, never touching the outer context `dispatch`
|
|
1320
|
+
// runs under) — this load exists purely to surface `.warnings`, not to
|
|
1321
|
+
// narrate again.
|
|
1322
|
+
const narrator = yield* Narrator
|
|
1323
|
+
const config = yield* (yield* ConfigService).load.pipe(
|
|
1324
|
+
Effect.provideService(Narrator, { narrate: () => Effect.void, warn: () => Effect.void }),
|
|
1325
|
+
)
|
|
1326
|
+
for (const warning of config.warnings) {
|
|
1327
|
+
yield* narrator.warn(`gtd: warning: ${formatDiagnostic(warning)}`)
|
|
1328
|
+
}
|
|
1329
|
+
yield* dispatch
|
|
1330
|
+
})
|
|
1331
|
+
}
|