@pmelab/gtd 23.2.0 → 24.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -11
- package/claude/hooks/access.ts +1 -1
- package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
- package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
- package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
- package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
- package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
- package/dist/flows.mjs +525 -0
- package/dist/gtd.bundle.mjs +43830 -56044
- package/dist/steering.mjs +2 -0
- package/dist/testkit.mjs +13006 -0
- package/dist/wire.mjs +2 -0
- package/dist/workflow-kit.mjs +352 -0
- package/dist/workflows/feature.mjs +315 -0
- package/dist/workflows/fix.mjs +40 -0
- package/dist/workflows/review.mjs +25 -0
- package/dist/workflows.mjs +1953 -1724
- package/package.json +70 -8
- package/skills/authoring/SKILL.md +5 -4
- package/src/cli/Cli.test.ts +1329 -0
- package/src/cli/Cli.ts +1348 -0
- package/src/cli/Program.test.ts +2262 -0
- package/src/cli/Program.ts +1331 -0
- package/src/cli/ToolCommands.ts +43 -0
- package/src/cli/index.ts +14 -0
- package/src/edge/Edge.test.ts +1016 -0
- package/src/edge/Edge.ts +1225 -0
- package/src/edge/index.ts +16 -0
- package/src/emit/Emit.test.ts +425 -0
- package/src/emit/Emit.ts +139 -0
- package/src/emit/GitScript.test.ts +260 -0
- package/src/emit/GitScript.ts +131 -0
- package/src/emit/ModeContradiction.test.ts +120 -0
- package/src/emit/ModeContradiction.ts +66 -0
- package/src/emit/OutcomeScript.test.ts +238 -0
- package/src/emit/OutcomeScript.ts +73 -0
- package/src/emit/SteeringMode.test.ts +274 -0
- package/src/emit/SteeringMode.ts +256 -0
- package/src/emit/index.ts +40 -0
- package/src/flows/factory.ts +5 -1
- package/src/flows/runtime.ts +3 -0
- package/src/install/ConfigScaffold.ts +19 -0
- package/src/install/Install.test.ts +426 -0
- package/src/install/Install.ts +634 -0
- package/src/install/Version.ts +27 -0
- package/src/install/index.ts +3 -0
- package/src/platform/CommandRunner.ts +74 -0
- package/src/platform/Commentary.test.ts +126 -0
- package/src/platform/Commentary.ts +92 -0
- package/src/platform/Git.test.ts +50 -0
- package/src/platform/Git.ts +552 -0
- package/src/platform/Host.ts +42 -0
- package/src/platform/RetainedHistory.test.ts +65 -0
- package/src/platform/RetainedHistory.ts +34 -0
- package/src/platform/Workspace.test.ts +669 -0
- package/src/platform/Workspace.ts +631 -0
- package/src/platform/WorktreeState.test.ts +162 -0
- package/src/platform/WorktreeState.ts +50 -0
- package/src/platform/index.ts +22 -0
- package/src/replay/Access.test.ts +53 -0
- package/src/replay/Access.ts +46 -0
- package/src/replay/Format.test.ts +20 -0
- package/src/replay/Format.ts +14 -0
- package/src/replay/Replay.test.ts +887 -0
- package/src/replay/Replay.ts +669 -0
- package/src/replay/Trailers.test.ts +117 -0
- package/src/replay/Trailers.ts +213 -0
- package/src/replay/Tree.test.ts +25 -0
- package/src/replay/Tree.ts +45 -0
- package/src/replay/index.ts +11 -0
- package/src/steering/Blocks.fixture.ts +6 -0
- package/src/steering/Blocks.test.ts +167 -0
- package/src/steering/Blocks.ts +321 -0
- package/src/steering/CodeThreads.test.ts +127 -0
- package/src/steering/CodeThreads.ts +197 -0
- package/src/steering/Edits.test.ts +19 -0
- package/src/steering/Edits.ts +20 -0
- package/src/steering/Eol.test.ts +16 -0
- package/src/steering/Eol.ts +2 -0
- package/src/steering/FORMATS.md +58 -0
- package/src/steering/Footnotes.test.ts +977 -0
- package/src/steering/Footnotes.ts +661 -0
- package/src/steering/Inline.fixture.ts +7 -0
- package/src/steering/Inline.test.ts +227 -0
- package/src/steering/Inline.ts +195 -0
- package/src/steering/MarkdownTree.test.ts +247 -0
- package/src/steering/MarkdownTree.ts +260 -0
- package/src/steering/Outline.ts +19 -0
- package/src/steering/SteeringFormat.test.ts +91 -0
- package/src/steering/SteeringFormat.ts +267 -0
- package/src/steering/SteeringFormats.test.ts +163 -0
- package/src/steering/Threads.ts +163 -0
- package/src/steering/contract.test.ts +264 -0
- package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
- package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
- package/src/steering/fixtures/free-form/notes.md +12 -0
- package/src/steering/fixtures/free-form/notes.read.json +42 -0
- package/src/steering/fixtures/qa/answered.md +23 -0
- package/src/steering/fixtures/qa/answered.read.json +119 -0
- package/src/steering/fixtures/qa/requirements.md +160 -0
- package/src/steering/fixtures/qa/requirements.read.json +475 -0
- package/src/steering/fixtures/review/review.md +76 -0
- package/src/steering/fixtures/review/review.read.json +237 -0
- package/src/steering/fixtures/review/ticked.md +23 -0
- package/src/steering/fixtures/review/ticked.read.json +99 -0
- package/src/steering/fixtures/review-notes/baseline.md +14 -0
- package/src/steering/fixtures/review-notes/noted.md +19 -0
- package/src/steering/freeform.test.ts +311 -0
- package/src/steering/freeform.ts +148 -0
- package/src/steering/index.test.ts +62 -0
- package/src/steering/index.ts +140 -0
- package/src/steering/qa.test.ts +2160 -0
- package/src/steering/qa.ts +1104 -0
- package/src/steering/review.test.ts +1829 -0
- package/src/steering/review.ts +1180 -0
- package/src/step/LandStep.ts +31 -0
- package/src/step/RepoSnapshot.ts +29 -0
- package/src/step/accessRefusal.test.ts +58 -0
- package/src/step/accessRefusal.ts +26 -0
- package/src/step/index.ts +5 -0
- package/src/step/planStart.test.ts +202 -0
- package/src/step/planStart.ts +167 -0
- package/src/step/planStep.test.ts +104 -0
- package/src/step/planStep.ts +89 -0
- package/src/step/snapshot.fixture.ts +28 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
- package/src/testkit/EmittedScriptRecognizer.ts +642 -0
- package/src/testkit/FakeGitOperations.ts +95 -0
- package/src/testkit/GitTiers.test.ts +11 -0
- package/src/testkit/GitTiers.ts +1052 -0
- package/src/testkit/InMemRepo.ts +467 -0
- package/src/testkit/Layers.test.ts +58 -0
- package/src/testkit/Layers.ts +241 -0
- package/src/testkit/StrictGitOperations.ts +31 -0
- package/src/testkit/TestRepo.test.ts +119 -0
- package/src/testkit/TestRepo.ts +274 -0
- package/src/testkit/cliIo.ts +36 -0
- package/src/testkit/index.ts +9 -0
- package/src/testkit/steps.ts +177 -0
- package/src/testkit/testkit.feature +65 -0
- package/src/wire/BeatDocument.test.ts +419 -0
- package/src/wire/BeatDocument.ts +114 -0
- package/src/wire/BeatPlain.test.ts +113 -0
- package/src/wire/BeatPlain.ts +127 -0
- package/src/wire/BeatStatus.test.ts +59 -0
- package/src/wire/BeatStatus.ts +81 -0
- package/src/wire/Demand.test.ts +142 -0
- package/src/wire/Demand.ts +108 -0
- package/src/wire/ExitCodes.test.ts +21 -0
- package/src/wire/ExitCodes.ts +16 -0
- package/src/wire/Glob.ts +29 -0
- package/src/wire/Judge.ts +22 -0
- package/src/wire/Land.test.ts +120 -0
- package/src/wire/Land.ts +75 -0
- package/src/wire/Select.test.ts +148 -0
- package/src/wire/Select.ts +78 -0
- package/src/wire/Sessions.test.ts +59 -0
- package/src/wire/Sessions.ts +39 -0
- package/src/wire/WireSchema.test.ts +162 -0
- package/src/wire/constants.ts +22 -0
- package/src/wire/golden/1/beat-capture.json +34 -0
- package/src/wire/golden/1/beat-message.json +34 -0
- package/src/wire/golden/1/beat-prompt.json +44 -0
- package/src/wire/golden/1/beat-script.json +34 -0
- package/src/wire/golden/1/beat-stalled.json +34 -0
- package/src/wire/golden/1/judge.json +14 -0
- package/src/wire/golden/1/land-noop.json +10 -0
- package/src/wire/golden/1/land.json +10 -0
- package/src/wire/index.ts +12 -0
- package/src/wire/types.ts +46 -0
- package/src/workflow/Diagnostic.test.ts +93 -0
- package/src/workflow/Diagnostic.ts +54 -0
- package/src/workflow/SteeringFormats.test.ts +27 -0
- package/src/workflow/SteeringFormats.ts +6 -0
- package/src/workflow/Workflow.ts +77 -0
- package/src/workflow/compile.test.ts +53 -0
- package/src/workflow/compile.ts +56 -0
- package/src/workflow/discovery.ts +122 -0
- package/src/workflow/fingerprint.test.ts +52 -0
- package/src/workflow/fingerprint.ts +53 -0
- package/src/workflow/index.ts +33 -0
- package/src/workflow/load.test.ts +1017 -0
- package/src/workflow/load.ts +836 -0
- package/src/workflow/skills.test.ts +109 -0
- package/src/workflow/skills.ts +90 -0
- package/src/workflow/vars.ts +33 -0
- package/src/workflow-kit/health.test.ts +30 -0
- package/src/workflow-kit/health.ts +86 -0
- package/src/workflow-kit/index.ts +7 -0
- package/src/workflow-kit/quality.test.ts +17 -0
- package/src/workflow-kit/quality.ts +45 -0
- package/src/workflow-kit/review.ts +324 -0
- package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
- package/src/workflows/feature/feature.test.ts +32 -0
- package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
- package/src/workflows/feature/index.ts +10 -0
- package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
- package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
- package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
- package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
- package/src/workflows/feature/review.ts +106 -0
- package/src/workflows/feature/steps.test.ts +82 -0
- package/src/workflows/feature/steps.ts +92 -0
- package/src/workflows/fix/fix.feature +80 -0
- package/src/workflows/fix/fix.test.ts +74 -0
- package/src/workflows/fix/fix.ts +52 -0
- package/src/workflows/fix/index.ts +6 -0
- package/src/workflows/index.ts +36 -2
- package/src/workflows/review/index.ts +6 -0
- package/src/workflows/review/review.test.ts +99 -0
- package/src/workflows/review/review.ts +33 -0
- package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
- package/src/workflows/{access.ts → shared/access.ts} +3 -5
- package/src/workflows/shared/config.fixture.ts +12 -0
- package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
- package/src/workflows/{health.ts → shared/health.ts} +38 -55
- package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
- package/src/workflows/{options.ts → shared/options.ts} +14 -2
- package/src/workflows/shared/reserved.ts +11 -0
- package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
- package/src/workflows/shared/scenarios.ts +35 -0
- package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
- package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
- package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
- package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
- package/src/workflows/shared/tail.test.ts +205 -0
- package/src/workflows/shared/tail.ts +135 -0
- package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
- package/src/workflows/{text.ts → shared/text.ts} +48 -7
- package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
- package/src/workflows/shared/workflows.steps.ts +7 -0
- package/src/workflows/review.ts +0 -362
- /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
- /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
- /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
package/src/cli/Cli.ts
ADDED
|
@@ -0,0 +1,1348 @@
|
|
|
1
|
+
import { NodeContext } from "@effect/platform-node"
|
|
2
|
+
import { Cause, Effect, Either, Layer } from "effect"
|
|
3
|
+
import {
|
|
4
|
+
GtdUsageError,
|
|
5
|
+
Narrator,
|
|
6
|
+
renderFailure,
|
|
7
|
+
GitService,
|
|
8
|
+
Host,
|
|
9
|
+
Workspace,
|
|
10
|
+
CommandRunner,
|
|
11
|
+
} from "../platform/index.js"
|
|
12
|
+
import { GTD_VERSION } from "../install/index.js"
|
|
13
|
+
import {
|
|
14
|
+
ConfigDiscovery,
|
|
15
|
+
ConfigService,
|
|
16
|
+
isSettingName,
|
|
17
|
+
SETTING_NAME_RULE,
|
|
18
|
+
} from "../workflow/index.js"
|
|
19
|
+
// The shared command types live in `Program.ts`, which imports nothing from
|
|
20
|
+
// here, so this dependency stays one-directional.
|
|
21
|
+
import {
|
|
22
|
+
runCommand,
|
|
23
|
+
SelectorUsageError,
|
|
24
|
+
type ArtifactOut,
|
|
25
|
+
type Command,
|
|
26
|
+
type CommandRequirements,
|
|
27
|
+
type JsonMode,
|
|
28
|
+
} from "./Program.js"
|
|
29
|
+
import type { ToolCommands } from "./ToolCommands.js"
|
|
30
|
+
import { EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR } from "../wire/index.js"
|
|
31
|
+
|
|
32
|
+
export type { Command, CommandRequirements } from "./Program.js"
|
|
33
|
+
export { needsOf, standaloneKinds } from "./Program.js"
|
|
34
|
+
|
|
35
|
+
export type CliPlan =
|
|
36
|
+
| { readonly kind: "output"; readonly stdout: string }
|
|
37
|
+
| {
|
|
38
|
+
readonly kind: "usage"
|
|
39
|
+
readonly stdout: string
|
|
40
|
+
readonly message: string
|
|
41
|
+
readonly json: boolean
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
readonly kind: "command"
|
|
45
|
+
readonly command: Command
|
|
46
|
+
readonly json: JsonMode
|
|
47
|
+
/** Whether `--verbose`/`-v` was present — gates the `Narrator` `Cli.ts` builds for this dispatch (see `runCli`). */
|
|
48
|
+
readonly verbose: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Flag table
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
type FlagScope = (kind: Command["kind"] | undefined) => boolean
|
|
56
|
+
|
|
57
|
+
interface FlagRow {
|
|
58
|
+
readonly name: string
|
|
59
|
+
/**
|
|
60
|
+
* `"optional"` is a third generic mode: a value only from the `=` form
|
|
61
|
+
* (`--json=<path>`), never consuming a following token — bare `--json`
|
|
62
|
+
* records presence with no value. See `tokenize`'s single generic branch.
|
|
63
|
+
*/
|
|
64
|
+
readonly arity: 0 | 1 | "optional"
|
|
65
|
+
readonly repeatable: boolean
|
|
66
|
+
readonly scope: FlagScope
|
|
67
|
+
/** All raw occurrences (already de-`=`-ed) → the decoded value, or an error message. */
|
|
68
|
+
readonly decode: (raws: readonly string[]) => Either.Either<unknown, string>
|
|
69
|
+
readonly scopeError: string
|
|
70
|
+
readonly valueHint: string
|
|
71
|
+
/** Pre-wrapped help lines (data, not generated prose) — see `renderHelp`. */
|
|
72
|
+
readonly help: readonly string[]
|
|
73
|
+
/** Flag names this one may never appear alongside — checked generically after parsing (see `conflictViolation`), never as a bespoke `if`. Defaults to none. */
|
|
74
|
+
readonly conflicts?: readonly string[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const nonNegativeNumber = (raw: string, flag: string): Either.Either<number, string> => {
|
|
78
|
+
const n = Number(raw)
|
|
79
|
+
if (raw.trim() === "" || !Number.isFinite(n) || n < 0) {
|
|
80
|
+
return Either.left(`gtd: ${flag} must be a non-negative number — got "${raw}"`)
|
|
81
|
+
}
|
|
82
|
+
return Either.right(n)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const FLAGS: readonly FlagRow[] = [
|
|
86
|
+
{
|
|
87
|
+
name: "--json",
|
|
88
|
+
arity: "optional",
|
|
89
|
+
repeatable: false,
|
|
90
|
+
scope: (kind) =>
|
|
91
|
+
kind === "next" ||
|
|
92
|
+
kind === "land" ||
|
|
93
|
+
kind === "judge" ||
|
|
94
|
+
kind === "judgeAnswer" ||
|
|
95
|
+
kind === "doors",
|
|
96
|
+
decode: ([raw]) => Either.right(raw ?? ""),
|
|
97
|
+
scopeError:
|
|
98
|
+
"gtd: --json is only valid for `gtd next`/`gtd land`/`gtd judge`/`gtd judge answer`/`gtd doors` — " +
|
|
99
|
+
"every other command prints plain text; see `gtd install` for the driver protocol briefing",
|
|
100
|
+
valueHint: "<path>",
|
|
101
|
+
help: [
|
|
102
|
+
"(gtd next/gtd land/gtd judge/gtd judge answer/gtd doors only) output",
|
|
103
|
+
"structured JSON. Bare --json prints the whole document;",
|
|
104
|
+
"--json=<path> (a dotted key path into that document, e.g.",
|
|
105
|
+
"kind, content, session.id) prints just that value: a",
|
|
106
|
+
"scalar raw and unquoted, a boolean as true/false, a list",
|
|
107
|
+
"one JSON entry per line — a numeric segment (e.g.",
|
|
108
|
+
"skills.0) indexes into one entry of a list field instead.",
|
|
109
|
+
"An absent optional field prints nothing and exits 0 —",
|
|
110
|
+
"including when an earlier segment of <path> is itself",
|
|
111
|
+
"absent/null (e.g. session.id at a non-prompt rest), which",
|
|
112
|
+
"never counts as unknown; an unknown path is a usage error",
|
|
113
|
+
"(exit 2).",
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "--port",
|
|
118
|
+
arity: 1,
|
|
119
|
+
repeatable: false,
|
|
120
|
+
scope: (kind) => kind === "ui",
|
|
121
|
+
decode: ([raw]) => {
|
|
122
|
+
const n = Number(raw)
|
|
123
|
+
return raw !== undefined && Number.isInteger(n) && n >= 0 && n <= 65535
|
|
124
|
+
? Either.right(n)
|
|
125
|
+
: Either.left(`gtd: --port must be an integer 0–65535 (got '${raw ?? ""}')`)
|
|
126
|
+
},
|
|
127
|
+
scopeError: "gtd: --port is only valid for `gtd ui`",
|
|
128
|
+
valueHint: "<n>",
|
|
129
|
+
help: [
|
|
130
|
+
"(gtd ui only) the tailscale serve port (default: the first",
|
|
131
|
+
"free of 8443, 10000, 443), or the bind port when --host",
|
|
132
|
+
"opts out of serve (default: a free port)",
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "--host",
|
|
137
|
+
arity: 1,
|
|
138
|
+
repeatable: false,
|
|
139
|
+
scope: (kind) => kind === "ui",
|
|
140
|
+
decode: ([raw]) =>
|
|
141
|
+
raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
|
|
142
|
+
? Either.left("gtd: --host must be a non-empty, single-line value")
|
|
143
|
+
: Either.right(raw),
|
|
144
|
+
scopeError: "gtd: --host is only valid for `gtd ui`",
|
|
145
|
+
valueHint: "<addr>",
|
|
146
|
+
help: [
|
|
147
|
+
"(gtd ui only) opt out of the default tailscale serve front",
|
|
148
|
+
"door and bind this address directly instead, showing it in",
|
|
149
|
+
"the printed URL",
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "--self-signed",
|
|
154
|
+
arity: 0,
|
|
155
|
+
repeatable: false,
|
|
156
|
+
scope: (kind) => kind === "ui",
|
|
157
|
+
decode: () => Either.right(true),
|
|
158
|
+
scopeError: "gtd: --self-signed is only valid for `gtd ui`",
|
|
159
|
+
valueHint: "",
|
|
160
|
+
help: [
|
|
161
|
+
"(gtd ui only) generate a throwaway self-signed TLS",
|
|
162
|
+
"certificate instead of the configured ui.cert/ui.key",
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "--dev",
|
|
167
|
+
arity: 0,
|
|
168
|
+
repeatable: false,
|
|
169
|
+
scope: (kind) => kind === "ui",
|
|
170
|
+
decode: () => Either.right(true),
|
|
171
|
+
scopeError: "gtd: --dev is only valid for `gtd ui`",
|
|
172
|
+
valueHint: "",
|
|
173
|
+
help: ["(gtd ui only) run against local development sources", "instead of the packaged build"],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
name: "--provider",
|
|
177
|
+
arity: 1,
|
|
178
|
+
repeatable: false,
|
|
179
|
+
scope: (kind) => kind === "judgeRun",
|
|
180
|
+
decode: ([raw]) =>
|
|
181
|
+
raw === "fixed" || raw === "jev" || raw === "llm"
|
|
182
|
+
? Either.right(raw)
|
|
183
|
+
: Either.left(`gtd: --provider must be one of fixed, jev, llm — got "${raw ?? ""}"`),
|
|
184
|
+
scopeError: "gtd: --provider is only valid for `gtd judge run`",
|
|
185
|
+
valueHint: "<name>",
|
|
186
|
+
help: [
|
|
187
|
+
"(gtd judge run only) the answerer: fixed | jev | llm;",
|
|
188
|
+
"omitted: GTD_JUDGE_PROVIDER, then judge.json, then jev",
|
|
189
|
+
"when TYPESAFE_API_KEY is set, else llm",
|
|
190
|
+
],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "--answers",
|
|
194
|
+
arity: 1,
|
|
195
|
+
repeatable: false,
|
|
196
|
+
scope: (kind) => kind === "judgeRun",
|
|
197
|
+
decode: ([raw]) =>
|
|
198
|
+
raw === undefined || raw === ""
|
|
199
|
+
? Either.left("gtd: --answers must be a non-empty path")
|
|
200
|
+
: Either.right(raw),
|
|
201
|
+
scopeError: "gtd: --answers is only valid for `gtd judge run`",
|
|
202
|
+
valueHint: "<path>",
|
|
203
|
+
help: [
|
|
204
|
+
"(gtd judge run --provider fixed only) a JSON verdict array",
|
|
205
|
+
"[{ id, answer, p }]; else the GTD_JUDGE_ANSWERS env var",
|
|
206
|
+
"(inline JSON) is read",
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: "--cost",
|
|
211
|
+
arity: 1,
|
|
212
|
+
repeatable: false,
|
|
213
|
+
scope: (kind) => kind === "land",
|
|
214
|
+
decode: ([raw]) => nonNegativeNumber(raw ?? "", "--cost"),
|
|
215
|
+
scopeError: "gtd: --cost is only valid for `gtd land` — a start is not a metered agent turn",
|
|
216
|
+
valueHint: "<n>",
|
|
217
|
+
help: ["(gtd land only) record the invocation's token cost"],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "--model",
|
|
221
|
+
arity: 1,
|
|
222
|
+
repeatable: false,
|
|
223
|
+
scope: (kind) => kind === "land" || kind === "judgeRun",
|
|
224
|
+
decode: ([raw]) =>
|
|
225
|
+
raw === undefined || raw.trim() === "" || /[\r\n]/.test(raw)
|
|
226
|
+
? Either.left("gtd: --model must be a non-empty, single-line value")
|
|
227
|
+
: Either.right(raw),
|
|
228
|
+
scopeError: "gtd: --model is only valid for `gtd land` (with --cost) or `gtd judge run`",
|
|
229
|
+
valueHint: "<name>",
|
|
230
|
+
help: [
|
|
231
|
+
"(gtd land, with --cost) tag that cost's model",
|
|
232
|
+
"(gtd judge run, llm answerer) the claude model; omitted:",
|
|
233
|
+
"GTD_JUDGE_MODEL, then judge.json, then haiku",
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "--workflow",
|
|
238
|
+
arity: 1,
|
|
239
|
+
repeatable: false,
|
|
240
|
+
scope: (kind) => kind === "start",
|
|
241
|
+
decode: ([raw]) => Either.right(raw ?? ""),
|
|
242
|
+
scopeError:
|
|
243
|
+
"gtd: --workflow is only valid with no other command — use the bare `gtd --workflow <name>` " +
|
|
244
|
+
"form; landing and starting are different verbs",
|
|
245
|
+
valueHint: "<name>",
|
|
246
|
+
help: [
|
|
247
|
+
"(with no command at all) start a brand new process on",
|
|
248
|
+
"the named workflow — authenticated as human",
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "--var",
|
|
253
|
+
arity: 1,
|
|
254
|
+
repeatable: true,
|
|
255
|
+
scope: (kind) => kind === "start",
|
|
256
|
+
decode: (raws) => {
|
|
257
|
+
const vars: Record<string, string> = {}
|
|
258
|
+
const seen = new Set<string>()
|
|
259
|
+
for (const raw of raws) {
|
|
260
|
+
const eq = raw.indexOf("=")
|
|
261
|
+
if (eq <= 0) {
|
|
262
|
+
return Either.left(
|
|
263
|
+
`gtd: --var must be <name>=<value> with a non-empty name — got "${raw}"`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
const name = raw.slice(0, eq)
|
|
267
|
+
const value = raw.slice(eq + 1)
|
|
268
|
+
if (!isSettingName(name)) {
|
|
269
|
+
return Either.left(
|
|
270
|
+
`gtd: --var ${JSON.stringify(name)} is not a valid setting name — ${SETTING_NAME_RULE}`,
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
if (/[\r\n]/.test(value)) {
|
|
274
|
+
return Either.left(`gtd: --var ${name} must be a single-line value`)
|
|
275
|
+
}
|
|
276
|
+
if (seen.has(name)) {
|
|
277
|
+
return Either.left(`gtd: --var ${name} specified more than once`)
|
|
278
|
+
}
|
|
279
|
+
seen.add(name)
|
|
280
|
+
vars[name] = value
|
|
281
|
+
}
|
|
282
|
+
return Either.right(vars)
|
|
283
|
+
},
|
|
284
|
+
scopeError: "gtd: --var requires --workflow",
|
|
285
|
+
valueHint: "<name>=<value>",
|
|
286
|
+
help: [
|
|
287
|
+
"(with --workflow; repeatable) pin a process setting for the",
|
|
288
|
+
"new process; the name must already be declared by the",
|
|
289
|
+
"workflow's defaults (environment settings are not",
|
|
290
|
+
"pinnable)",
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
name: "--open-questions",
|
|
295
|
+
arity: 0,
|
|
296
|
+
repeatable: false,
|
|
297
|
+
scope: (kind) => kind === "check",
|
|
298
|
+
decode: () => Either.right(true),
|
|
299
|
+
scopeError: "gtd: --open-questions is only valid for `gtd check`",
|
|
300
|
+
valueHint: "",
|
|
301
|
+
help: [
|
|
302
|
+
"(gtd check only) ignore <mode>'s structural findings and",
|
|
303
|
+
"instead run the qa open-questions predicate over <file>,",
|
|
304
|
+
"printing each unanswered question one per line and exiting",
|
|
305
|
+
"non-zero when any remain",
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
name: "--open-threads",
|
|
310
|
+
arity: 0,
|
|
311
|
+
repeatable: false,
|
|
312
|
+
scope: (kind) => kind === "check",
|
|
313
|
+
decode: () => Either.right(true),
|
|
314
|
+
scopeError: "gtd: --open-threads is only valid for `gtd check`",
|
|
315
|
+
valueHint: "",
|
|
316
|
+
help: [
|
|
317
|
+
"(gtd check only) ignore <mode>'s structural findings and",
|
|
318
|
+
"instead list every open thread (a footnote whose last",
|
|
319
|
+
"entry is the agent's) over <file>, one per line with its",
|
|
320
|
+
"first H entry, exiting non-zero when any are open. With no",
|
|
321
|
+
"<mode>/<file>, lists code-comment threads in the files the",
|
|
322
|
+
"current process changed (read from its diff base, unlike",
|
|
323
|
+
"the rest of `gtd check`)",
|
|
324
|
+
],
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
name: "--verbose",
|
|
328
|
+
arity: 0,
|
|
329
|
+
repeatable: false,
|
|
330
|
+
scope: () => true,
|
|
331
|
+
decode: () => Either.right(true),
|
|
332
|
+
scopeError: "gtd: --verbose is valid for every command — this error is unreachable",
|
|
333
|
+
valueHint: "",
|
|
334
|
+
help: [
|
|
335
|
+
"enable stderr narration for this invocation: which rest",
|
|
336
|
+
"resolved and how config resolved across layers. Aliased",
|
|
337
|
+
"to -v",
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* `-v` is `--verbose`'s single-dash alias (the only one today) — resolved to
|
|
344
|
+
* its canonical `--` name before flag-row lookup, so the rest of the
|
|
345
|
+
* tokenizer never distinguishes how a flag was spelled. A `Map`, not a plain
|
|
346
|
+
* object: an object literal inherits `Object.prototype`, so a positional
|
|
347
|
+
* argument that happens to spell an inherited property name (`"valueOf"`,
|
|
348
|
+
* `"toString"`, …) would otherwise resolve to that inherited FUNCTION instead
|
|
349
|
+
* of `undefined`.
|
|
350
|
+
*/
|
|
351
|
+
const SINGLE_DASH_ALIASES: ReadonlyMap<string, string> = new Map([["-v", "--verbose"]])
|
|
352
|
+
|
|
353
|
+
const flagByToken = (token: string): FlagRow | undefined => {
|
|
354
|
+
const eq = token.indexOf("=")
|
|
355
|
+
const bareName = eq === -1 ? token : token.slice(0, eq)
|
|
356
|
+
return FLAGS.find((f) => f.name === bareName)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// Command table
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
type Arity =
|
|
364
|
+
| "none"
|
|
365
|
+
| { readonly name: string }
|
|
366
|
+
| { readonly names: readonly [string, string] }
|
|
367
|
+
// One required leading positional, then any number the command itself judges.
|
|
368
|
+
| { readonly name: string; readonly variadic: string }
|
|
369
|
+
|
|
370
|
+
interface CommandRow {
|
|
371
|
+
readonly token: string
|
|
372
|
+
readonly kind: Command["kind"]
|
|
373
|
+
readonly arity: Arity
|
|
374
|
+
readonly details: readonly string[]
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const COMMAND_ROWS: readonly CommandRow[] = [
|
|
378
|
+
{
|
|
379
|
+
token: "land",
|
|
380
|
+
kind: "land",
|
|
381
|
+
arity: "none",
|
|
382
|
+
details: [
|
|
383
|
+
"Land whatever the tree now shows at the currently resolved",
|
|
384
|
+
"rest — a human capture, an agent/check turn, or an empty",
|
|
385
|
+
"attempt (a fruitless prompt turn). Pass",
|
|
386
|
+
"--cost=<n> (optionally --model=<name>) to record the",
|
|
387
|
+
"just-finished invocation's token cost and model on the",
|
|
388
|
+
"turn commit (summed into it.processCost/",
|
|
389
|
+
"processCostByModel). Plain (the default) prints one",
|
|
390
|
+
"human-readable sentence naming the commit subject (or a",
|
|
391
|
+
"no-op note) plus a pointer to `gtd land --json=script | sh`",
|
|
392
|
+
"to get the landing script — never the script itself.",
|
|
393
|
+
"--json emits script (the actual POSIX sh",
|
|
394
|
+
"a driver runs) alongside settled, idle, state (the",
|
|
395
|
+
"post-land target), subject, cost and model. Exits 0 on",
|
|
396
|
+
"success, 1 on any refusal — see the Exit codes section",
|
|
397
|
+
"below",
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
token: "abandon",
|
|
402
|
+
kind: "abandon",
|
|
403
|
+
arity: "none",
|
|
404
|
+
details: [
|
|
405
|
+
"End the process currently underway without completing it:",
|
|
406
|
+
"rewind HEAD to the commit the process started from,",
|
|
407
|
+
"keeping everything it produced as uncommitted changes. A",
|
|
408
|
+
"no-op when no process is underway",
|
|
409
|
+
],
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
token: "restore",
|
|
413
|
+
kind: "restore",
|
|
414
|
+
arity: "none",
|
|
415
|
+
details: [
|
|
416
|
+
"Hard-reset HEAD back to the tip retained by the last",
|
|
417
|
+
"abandon (refs/worktree/gtd/history), bringing back an",
|
|
418
|
+
"abandoned process's turns. Refuses on a dirty working",
|
|
419
|
+
"tree, when there is no retained history, or when HEAD has",
|
|
420
|
+
"advanced past the retained tip with commits that would be",
|
|
421
|
+
"lost",
|
|
422
|
+
],
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
token: "next",
|
|
426
|
+
kind: "next",
|
|
427
|
+
arity: "none",
|
|
428
|
+
details: [
|
|
429
|
+
"Print the resolved rest's beat (no mutation, safe to",
|
|
430
|
+
"poll), in one of three encodings. Plain (the default): a",
|
|
431
|
+
"status summary, a blank line, then the step verbatim — at a",
|
|
432
|
+
"script/capture rest an instruction line ('Run this",
|
|
433
|
+
"script:' / 'The edit is already made — run `gtd land` to",
|
|
434
|
+
"land it.') precedes the status summary; at a prompt rest",
|
|
435
|
+
"it's the bare step (plus the self-validation instruction",
|
|
436
|
+
"when applicable) with no header at all, since those bytes",
|
|
437
|
+
"are the agent's own input. --json emits the one structured",
|
|
438
|
+
"surface gtd has: kind (capture|message|script|prompt|",
|
|
439
|
+
"stalled) selects what a driver does, content is what it",
|
|
440
|
+
"runs or shows, idle marks the workflow's initial state with",
|
|
441
|
+
"a clean tree, plus the prompt session, model, validate",
|
|
442
|
+
"script, log path, changes, next and the resting state's own",
|
|
443
|
+
"fields. --json=<path> (a dotted key path into that same",
|
|
444
|
+
"document, e.g. kind, content, session.id) prints just that",
|
|
445
|
+
"value instead of the whole document — see --json's own help",
|
|
446
|
+
"above. Exits 0 — see the Exit codes section below",
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
token: "validate",
|
|
451
|
+
kind: "validate",
|
|
452
|
+
arity: "none",
|
|
453
|
+
details: [
|
|
454
|
+
"Print the script that formats (when declared) then",
|
|
455
|
+
"validates the resolved rest's steering file, using its",
|
|
456
|
+
"mode's commands (its file:/mode:), instead of running it —",
|
|
457
|
+
"a driver runs the script and reads the findings from its",
|
|
458
|
+
'own exit code/output. Always exits 0; prints "nothing to',
|
|
459
|
+
'validate" when the resolved rest declares nothing to run.',
|
|
460
|
+
"On a non-zero validate exit the emitted script prints a",
|
|
461
|
+
"ready-to-send fix prompt (instruction + findings) and",
|
|
462
|
+
"exits with the validator's own code",
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
token: "lsp",
|
|
467
|
+
kind: "lsp",
|
|
468
|
+
arity: "none",
|
|
469
|
+
details: ["Start the LSP server for .gtd/ steering files (stdio)"],
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
token: "ui",
|
|
473
|
+
kind: "ui",
|
|
474
|
+
arity: "none",
|
|
475
|
+
details: [
|
|
476
|
+
"Expose gtd's web/phone client for THIS worktree — the",
|
|
477
|
+
"invoking directory, never a configured list of roots — and",
|
|
478
|
+
"refuses outside a repository like every other state command.",
|
|
479
|
+
"By default, publishes through `tailscale serve` (reachable",
|
|
480
|
+
"from anywhere on the tailnet, including over a DERP relay)",
|
|
481
|
+
"with tailscaled terminating TLS, walking 8443, then 10000,",
|
|
482
|
+
"then 443 until one publishes; falls back to binding a local",
|
|
483
|
+
"HTTPS server directly, never refusing, when none of the",
|
|
484
|
+
"three works. --host <addr> opts out of serve and binds that",
|
|
485
|
+
"address directly instead; --port <n> overrides the serve",
|
|
486
|
+
"port (default: the first free of 8443, 10000, 443), or the",
|
|
487
|
+
"bind port when --host is given (default: a free port);",
|
|
488
|
+
"--self-signed also opts out of serve, generating a",
|
|
489
|
+
"throwaway TLS certificate instead of the configured",
|
|
490
|
+
"ui.cert/ui.key; --dev runs against local development sources",
|
|
491
|
+
"instead of the packaged build",
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
token: "check",
|
|
496
|
+
kind: "check",
|
|
497
|
+
arity: { names: ["mode", "file"] },
|
|
498
|
+
details: [
|
|
499
|
+
"Read <file> and run the built-in steering format named",
|
|
500
|
+
"<mode> (see `gtd validate`'s modes: qa, review) over its",
|
|
501
|
+
"contents, printing each finding one per line and exiting",
|
|
502
|
+
"non-zero when there are any. Resolves no workflow state and",
|
|
503
|
+
"reads no config — standalone, runnable from any directory",
|
|
504
|
+
"with <mode>/<file> given explicitly. This is what a",
|
|
505
|
+
"workflow's emitted validation script invokes as a leaf step.",
|
|
506
|
+
"--open-questions runs the qa unanswered-questions predicate",
|
|
507
|
+
"instead (see --help); --open-threads lists open threads.",
|
|
508
|
+
"`gtd check --open-threads` alone lists open code-comment",
|
|
509
|
+
"threads (`// H: ...`) in the files the process changed;",
|
|
510
|
+
"it prints nothing when no process is underway.",
|
|
511
|
+
],
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
token: "uncheck",
|
|
515
|
+
kind: "uncheck",
|
|
516
|
+
arity: { name: "file" },
|
|
517
|
+
details: [
|
|
518
|
+
"Read <file> and reset every review-mode `- [x]`/`- [X]`",
|
|
519
|
+
"file-pointer box back to `- [ ]`, writing the result back",
|
|
520
|
+
"only when the bytes actually changed. Resolves no workflow",
|
|
521
|
+
"state and reads no config — standalone, runnable from any",
|
|
522
|
+
"directory with <file> given explicitly. This is what the",
|
|
523
|
+
"landing script runs ahead of the commit at the human review",
|
|
524
|
+
"gate, so no tick ever reaches a commit. Takes no <mode> —",
|
|
525
|
+
"it means review-mode file pointers and nothing else, never",
|
|
526
|
+
"qa-mode's answered-question boxes. A missing file writes",
|
|
527
|
+
"nothing and exits 0",
|
|
528
|
+
],
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
token: "install",
|
|
532
|
+
kind: "install",
|
|
533
|
+
arity: "none",
|
|
534
|
+
details: [
|
|
535
|
+
"Print a complete, self-contained briefing that teaches an",
|
|
536
|
+
"agent (or a human) to build a gtd driver in any shell or",
|
|
537
|
+
"runtime — the self-serve version of",
|
|
538
|
+
"https://github.com/pmelab/gtd/blob/main/docs/driver.md's",
|
|
539
|
+
"'Writing your own driver'. At a repository root no",
|
|
540
|
+
"gtd.config.ts governs yet, it also writes one importing each",
|
|
541
|
+
"shipped workflow from @pmelab/gtd/workflows/<name>; anywhere",
|
|
542
|
+
"else it writes nothing.",
|
|
543
|
+
],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
token: "loop",
|
|
547
|
+
kind: "loop",
|
|
548
|
+
arity: "none",
|
|
549
|
+
details: [
|
|
550
|
+
"Drive this worktree's process until it rests on you: read",
|
|
551
|
+
"each beat, run the agent turn or script it demands, and",
|
|
552
|
+
"land the result. Exits 0 at a human gate, at idle and when",
|
|
553
|
+
"a landing settles; exits 1 on a stall or a failed turn or",
|
|
554
|
+
"landing. The agent CLI and the model each workflow hint",
|
|
555
|
+
"maps to are per-machine settings (GTD_LOOP_* variables, or",
|
|
556
|
+
"~/.config/gtd/loop.json); see docs/driver.md",
|
|
557
|
+
],
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
token: "summary",
|
|
561
|
+
kind: "summary",
|
|
562
|
+
arity: "none",
|
|
563
|
+
details: [
|
|
564
|
+
"Print the prompt for an agent to write the process HEAD",
|
|
565
|
+
"closes or sits inside its own closing message — the entry",
|
|
566
|
+
"commit, each human-authored commit (a review round, an",
|
|
567
|
+
"answered question gate), the diff range to inspect, and",
|
|
568
|
+
"it.processCost/processCostByModel. Writes nothing: no git,",
|
|
569
|
+
"no state transition, no file, no session identity — the",
|
|
570
|
+
"driver pipes the output to a cold agent and does what it",
|
|
571
|
+
"wants with the result (a squash, an amend, a PR body).",
|
|
572
|
+
"Refuses (exit 1) when the workflow declares no summary:",
|
|
573
|
+
"template, or when the resolved run has no commits to name",
|
|
574
|
+
"— runnable any time before the next thing lands on the",
|
|
575
|
+
"branch",
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
token: "base",
|
|
580
|
+
kind: "base",
|
|
581
|
+
arity: "none",
|
|
582
|
+
details: [
|
|
583
|
+
"Print the review anchor hash — the diff base an external",
|
|
584
|
+
"tool (a diff, a PR tool, another agent) should point at —",
|
|
585
|
+
"bare, newline-terminated, and nothing else. Writes nothing:",
|
|
586
|
+
"no git, no state transition, no session identity. Before",
|
|
587
|
+
"the first review round it's the process's diff base;",
|
|
588
|
+
"afterward it's the most-recent review round's boundary.",
|
|
589
|
+
"Refuses (exit 1) when no process is underway.",
|
|
590
|
+
],
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
token: "door",
|
|
594
|
+
kind: "door",
|
|
595
|
+
arity: { name: "name", variadic: "args" },
|
|
596
|
+
details: [
|
|
597
|
+
"Start a process through a named shortcut, mapping the",
|
|
598
|
+
"positional args to that workflow's process settings — the",
|
|
599
|
+
"same start `gtd --workflow` makes, printed the same way.",
|
|
600
|
+
"Each workflow the config exports brings its own doors (gtd's",
|
|
601
|
+
"`fix` brings `fix`, `review` brings `review [base]`), plus",
|
|
602
|
+
"the config's `doors` export. An unknown door, or too few or",
|
|
603
|
+
"too many args, is a usage error (exit 2) naming the doors or",
|
|
604
|
+
"the door's synopsis",
|
|
605
|
+
],
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
token: "doors",
|
|
609
|
+
kind: "doors",
|
|
610
|
+
arity: "none",
|
|
611
|
+
details: [
|
|
612
|
+
"List every door, sorted: one `name [args] → workflow` line",
|
|
613
|
+
"each. --json prints one JSON array of { name, workflow,",
|
|
614
|
+
"args: [{ name, optional }] } instead; --json=<path> reads one",
|
|
615
|
+
"value off it. Writes nothing",
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
token: "judge",
|
|
620
|
+
kind: "judge",
|
|
621
|
+
arity: "none",
|
|
622
|
+
details: [
|
|
623
|
+
"Print the resolved rest's pending judgment — the prepared",
|
|
624
|
+
"state, its typed questions, and their criteria — the same",
|
|
625
|
+
"judge field `gtd next --json` already carries. Read-only:",
|
|
626
|
+
"resolves no session, writes nothing. Refuses (exit 1) when",
|
|
627
|
+
"the resolved rest declares no judge:",
|
|
628
|
+
],
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
token: "judge answer",
|
|
632
|
+
kind: "judgeAnswer",
|
|
633
|
+
arity: "none",
|
|
634
|
+
details: [
|
|
635
|
+
"Read a verdict off stdin — one { id, answer, p } entry per",
|
|
636
|
+
"question the pending judgment declared — and decode it",
|
|
637
|
+
"against an Effect Schema built from those same question",
|
|
638
|
+
"ids. Refuses (exit 1) when the resolved rest declares no",
|
|
639
|
+
"judge:. Exits 2 (a usage error, like an unknown --json",
|
|
640
|
+
"selector) when stdin isn't valid JSON or the verdict",
|
|
641
|
+
"doesn't decode against the pending question ids.",
|
|
642
|
+
],
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
token: "judge run",
|
|
646
|
+
kind: "judgeRun",
|
|
647
|
+
arity: "none",
|
|
648
|
+
details: [
|
|
649
|
+
"Answer a judgment: read the `gtd judge --json` document off",
|
|
650
|
+
"stdin, write a verdict [{ id, answer, p }] on stdout — what",
|
|
651
|
+
"`gtd judge answer` decodes. --provider fixed answers from",
|
|
652
|
+
"--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
|
|
653
|
+
"does not cover are left out. --provider jev asks TypeSafe",
|
|
654
|
+
"(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
|
|
655
|
+
"on PATH (Claude Code only), model haiku unless --model.",
|
|
656
|
+
"No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
|
|
657
|
+
"judge.json, else jev when TYPESAFE_API_KEY is set and no",
|
|
658
|
+
"model is given, else llm; --model likewise falls back to",
|
|
659
|
+
"GTD_JUDGE_MODEL, then the file's model. Needs no",
|
|
660
|
+
"repository. jev and llm exit 1 when they cannot answer",
|
|
661
|
+
"every question, with nothing on stdout",
|
|
662
|
+
],
|
|
663
|
+
},
|
|
664
|
+
]
|
|
665
|
+
|
|
666
|
+
const commandByToken = (token: string): CommandRow | undefined =>
|
|
667
|
+
COMMAND_ROWS.find((r) => r.token === token)
|
|
668
|
+
|
|
669
|
+
// ---------------------------------------------------------------------------
|
|
670
|
+
// Help rendering
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
const COLUMN = 19
|
|
674
|
+
|
|
675
|
+
const renderBlock = (header: string, lines: readonly string[]): string => {
|
|
676
|
+
const [first, ...rest] = lines
|
|
677
|
+
const headerCell = ` ${header}`
|
|
678
|
+
const headerLine =
|
|
679
|
+
headerCell.length < COLUMN
|
|
680
|
+
? `${headerCell.padEnd(COLUMN)}${first ?? ""}\n`
|
|
681
|
+
: `${headerCell}\n${" ".repeat(COLUMN)}${first ?? ""}\n`
|
|
682
|
+
return headerLine + rest.map((l) => `${" ".repeat(COLUMN)}${l}\n`).join("")
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const START_SHORT_FORM = renderBlock("(no command) --workflow <name>", [
|
|
686
|
+
"Starts a new process authenticated as human, e.g.",
|
|
687
|
+
"'gtd --workflow <name>'",
|
|
688
|
+
])
|
|
689
|
+
|
|
690
|
+
const VERSION_BLOCK = renderBlock("version", ["Print version and exit"])
|
|
691
|
+
const HELP_BLOCK = renderBlock("help", ["Print this help and exit"])
|
|
692
|
+
|
|
693
|
+
const SPACE_FORM_FLAGS = new Set(["--workflow", "--var"])
|
|
694
|
+
|
|
695
|
+
const flagHeader = (row: FlagRow): string => {
|
|
696
|
+
if (row.valueHint === "") return row.name
|
|
697
|
+
return SPACE_FORM_FLAGS.has(row.name)
|
|
698
|
+
? `${row.name} ${row.valueHint}`
|
|
699
|
+
: `${row.name}=${row.valueHint}`
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export const renderHelp = (): string => {
|
|
703
|
+
const commandBlocks = COMMAND_ROWS.map((row) => {
|
|
704
|
+
const header =
|
|
705
|
+
row.arity === "none"
|
|
706
|
+
? row.token
|
|
707
|
+
: "names" in row.arity
|
|
708
|
+
? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>`
|
|
709
|
+
: "variadic" in row.arity
|
|
710
|
+
? `${row.token} <${row.arity.name}> [${row.arity.variadic}...]`
|
|
711
|
+
: `${row.token} <${row.arity.name}>`
|
|
712
|
+
return renderBlock(header, row.details)
|
|
713
|
+
})
|
|
714
|
+
const commands = [
|
|
715
|
+
commandBlocks[0], // land
|
|
716
|
+
START_SHORT_FORM,
|
|
717
|
+
...commandBlocks.slice(1), // abandon..base
|
|
718
|
+
VERSION_BLOCK,
|
|
719
|
+
HELP_BLOCK,
|
|
720
|
+
].join("")
|
|
721
|
+
|
|
722
|
+
const options = FLAGS.map((row) => renderBlock(flagHeader(row), row.help)).join("")
|
|
723
|
+
const versionHelpOptions =
|
|
724
|
+
renderBlock("--version, -V", ["Print version and exit"]) +
|
|
725
|
+
renderBlock("--help, -h", ["Print this help and exit"])
|
|
726
|
+
|
|
727
|
+
return (
|
|
728
|
+
`Usage: gtd [command] [options]\n\n` +
|
|
729
|
+
`Commands:\n${commands}\n` +
|
|
730
|
+
`Options:\n${options}${versionHelpOptions}`
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ---------------------------------------------------------------------------
|
|
735
|
+
// Tokenizer
|
|
736
|
+
// ---------------------------------------------------------------------------
|
|
737
|
+
|
|
738
|
+
interface TokenizeError {
|
|
739
|
+
readonly kind: "error"
|
|
740
|
+
readonly message: string
|
|
741
|
+
readonly jsonSeen: boolean
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
interface TokenizeOk {
|
|
745
|
+
readonly kind: "ok"
|
|
746
|
+
readonly positionals: readonly string[]
|
|
747
|
+
readonly byFlag: ReadonlyMap<string, readonly string[]>
|
|
748
|
+
readonly present: ReadonlySet<string>
|
|
749
|
+
readonly jsonSeen: boolean
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// fallow-ignore-next-line complexity
|
|
753
|
+
const tokenize = (tail: readonly string[]): TokenizeError | TokenizeOk => {
|
|
754
|
+
const positionals: string[] = []
|
|
755
|
+
const byFlag = new Map<string, string[]>()
|
|
756
|
+
const present = new Set<string>()
|
|
757
|
+
let jsonSeen = false
|
|
758
|
+
|
|
759
|
+
for (let i = 0; i < tail.length; i++) {
|
|
760
|
+
const original = tail[i]!
|
|
761
|
+
const tok = SINGLE_DASH_ALIASES.get(original) ?? original
|
|
762
|
+
if (!tok.startsWith("--")) {
|
|
763
|
+
positionals.push(original)
|
|
764
|
+
continue
|
|
765
|
+
}
|
|
766
|
+
const row = flagByToken(tok)
|
|
767
|
+
if (row === undefined) {
|
|
768
|
+
return {
|
|
769
|
+
kind: "error",
|
|
770
|
+
message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
|
|
771
|
+
jsonSeen,
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const eq = tok.indexOf("=")
|
|
775
|
+
const alreadyPresent = present.has(row.name)
|
|
776
|
+
present.add(row.name)
|
|
777
|
+
if (row.name === "--json") jsonSeen = true
|
|
778
|
+
|
|
779
|
+
if (row.arity === 0) {
|
|
780
|
+
if (eq !== -1) {
|
|
781
|
+
return {
|
|
782
|
+
kind: "error",
|
|
783
|
+
message: `gtd: unknown option '${tok}' — see \`gtd --help\``,
|
|
784
|
+
jsonSeen,
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (!byFlag.has(row.name)) byFlag.set(row.name, [])
|
|
788
|
+
continue
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (row.arity === "optional") {
|
|
792
|
+
if (!row.repeatable && alreadyPresent) {
|
|
793
|
+
return { kind: "error", message: `gtd: ${row.name} may be given at most once`, jsonSeen }
|
|
794
|
+
}
|
|
795
|
+
if (eq !== -1) {
|
|
796
|
+
const value = tok.slice(eq + 1)
|
|
797
|
+
if (value === "") {
|
|
798
|
+
// Unlike arity-1, an "optional" flag's bare/space form is a
|
|
799
|
+
// deliberate, DIFFERENT meaning (presence with no value) — never a
|
|
800
|
+
// rejected value-missing form — so this message names only the
|
|
801
|
+
// one legal way to supply a value, `=`, not the arity-1 template's
|
|
802
|
+
// "or <name> <value>" (which would recommend the very space form
|
|
803
|
+
// the settled decisions make a usage error on purpose).
|
|
804
|
+
return {
|
|
805
|
+
kind: "error",
|
|
806
|
+
message: `gtd: ${row.name} requires a value — use ${row.name}=${row.valueHint}`,
|
|
807
|
+
jsonSeen,
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
byFlag.set(row.name, [value])
|
|
811
|
+
continue
|
|
812
|
+
}
|
|
813
|
+
// Bare presence — never consumes the following token, unlike arity 1.
|
|
814
|
+
if (!byFlag.has(row.name)) byFlag.set(row.name, [])
|
|
815
|
+
continue
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
let raw: string
|
|
819
|
+
if (eq !== -1) {
|
|
820
|
+
raw = tok.slice(eq + 1)
|
|
821
|
+
} else {
|
|
822
|
+
const next = tail[i + 1]
|
|
823
|
+
if (next === undefined || next.startsWith("--")) {
|
|
824
|
+
raw = ""
|
|
825
|
+
} else {
|
|
826
|
+
raw = next
|
|
827
|
+
i++
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (raw === "") {
|
|
831
|
+
return {
|
|
832
|
+
kind: "error",
|
|
833
|
+
message: `gtd: ${row.name} requires a value — use ${row.name}=${row.valueHint} or ${row.name} ${row.valueHint}`,
|
|
834
|
+
jsonSeen,
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const existing = byFlag.get(row.name) ?? []
|
|
838
|
+
if (!row.repeatable && existing.length >= 1) {
|
|
839
|
+
return { kind: "error", message: `gtd: ${row.name} may be given at most once`, jsonSeen }
|
|
840
|
+
}
|
|
841
|
+
byFlag.set(row.name, [...existing, raw])
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return { kind: "ok", positionals, byFlag, present, jsonSeen }
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// ---------------------------------------------------------------------------
|
|
848
|
+
// parseArgv
|
|
849
|
+
// ---------------------------------------------------------------------------
|
|
850
|
+
|
|
851
|
+
const usagePlan = (message: string, json: boolean, stdout = ""): CliPlan => ({
|
|
852
|
+
kind: "usage",
|
|
853
|
+
stdout,
|
|
854
|
+
message,
|
|
855
|
+
json,
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
const scopeViolation = (
|
|
859
|
+
kind: Command["kind"] | undefined,
|
|
860
|
+
present: ReadonlySet<string>,
|
|
861
|
+
): string | undefined => {
|
|
862
|
+
for (const row of FLAGS) {
|
|
863
|
+
if (present.has(row.name) && !row.scope(kind)) return row.scopeError
|
|
864
|
+
}
|
|
865
|
+
return undefined
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** A table field, not an `if`: any row present alongside a flag it names in `conflicts` is a usage error — checked generically over every row, so a new conflicting pair is one table edit. */
|
|
869
|
+
const conflictViolation = (present: ReadonlySet<string>): string | undefined => {
|
|
870
|
+
for (const row of FLAGS) {
|
|
871
|
+
if (!present.has(row.name)) continue
|
|
872
|
+
for (const other of row.conflicts ?? []) {
|
|
873
|
+
if (present.has(other)) return `gtd: ${row.name} and ${other} are mutually exclusive`
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return undefined
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const arityError = (cmd: string, rest: readonly string[], arity: Arity): string | undefined => {
|
|
880
|
+
if (arity === "none") {
|
|
881
|
+
return rest.length > 0
|
|
882
|
+
? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}`
|
|
883
|
+
: undefined
|
|
884
|
+
}
|
|
885
|
+
if ("names" in arity) return pairArityError(cmd, rest, arity.names)
|
|
886
|
+
if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`
|
|
887
|
+
if (!("variadic" in arity) && rest.length > 1) {
|
|
888
|
+
return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`
|
|
889
|
+
}
|
|
890
|
+
return undefined
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const pairArityError = (
|
|
894
|
+
cmd: string,
|
|
895
|
+
rest: readonly string[],
|
|
896
|
+
[first, second]: readonly [string, string],
|
|
897
|
+
): string | undefined => {
|
|
898
|
+
if (rest.length === 0) return `gtd ${cmd}: missing ${first} and ${second} arguments`
|
|
899
|
+
if (rest.length === 1) return `gtd ${cmd}: missing ${second} argument`
|
|
900
|
+
if (rest.length > 2) {
|
|
901
|
+
return `gtd ${cmd}: too many arguments — expected ${first} and ${second}, got: ${rest.join(", ")}`
|
|
902
|
+
}
|
|
903
|
+
return undefined
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Decode every present flag into a bag of values, or the first decode error encountered. */
|
|
907
|
+
const decodeFlags = (
|
|
908
|
+
byFlag: ReadonlyMap<string, readonly string[]>,
|
|
909
|
+
): Either.Either<Readonly<Record<string, unknown>>, string> => {
|
|
910
|
+
const bag: Record<string, unknown> = {}
|
|
911
|
+
for (const row of FLAGS) {
|
|
912
|
+
const raws = byFlag.get(row.name)
|
|
913
|
+
if (raws === undefined) continue
|
|
914
|
+
// A bare `"optional"`-arity flag (e.g. `--json` with no `=`) has nothing
|
|
915
|
+
// to decode — presence alone is tracked via `present`, not the bag.
|
|
916
|
+
if (row.arity === "optional" && raws.length === 0) continue
|
|
917
|
+
const decoded = row.decode(raws)
|
|
918
|
+
if (Either.isLeft(decoded)) return Either.left(decoded.left)
|
|
919
|
+
bag[row.name] = decoded.right
|
|
920
|
+
}
|
|
921
|
+
return Either.right(bag)
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Assembles a `land` `Command` from its already-scope-checked flag bag — split out of `parseArgv` so its two independent, omit-when-absent optional fields don't inflate that function's own branching. */
|
|
925
|
+
const buildLandCommand = (bag: {
|
|
926
|
+
readonly "--cost"?: number
|
|
927
|
+
readonly "--model"?: string
|
|
928
|
+
}): Command => ({
|
|
929
|
+
kind: "land",
|
|
930
|
+
...(bag["--cost"] !== undefined ? { cost: bag["--cost"] } : {}),
|
|
931
|
+
...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
|
|
932
|
+
})
|
|
933
|
+
|
|
934
|
+
// fallow-ignore-next-line complexity
|
|
935
|
+
export const parseArgv = (argv: readonly string[]): CliPlan => {
|
|
936
|
+
if (argv.includes("--help") || argv.includes("-h"))
|
|
937
|
+
return { kind: "output", stdout: renderHelp() }
|
|
938
|
+
if (argv.includes("--version") || argv.includes("-V")) {
|
|
939
|
+
return { kind: "output", stdout: `${GTD_VERSION}\n` }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const tokenized = tokenize(argv.slice(2))
|
|
943
|
+
if (tokenized.kind === "error") return usagePlan(tokenized.message, tokenized.jsonSeen)
|
|
944
|
+
const { positionals, byFlag, present, jsonSeen } = tokenized
|
|
945
|
+
|
|
946
|
+
const first = positionals[0]
|
|
947
|
+
const workflowRaw = byFlag.get("--workflow")?.[0]
|
|
948
|
+
const workflowPresent = workflowRaw !== undefined
|
|
949
|
+
|
|
950
|
+
if (first === "version") return { kind: "output", stdout: `${GTD_VERSION}\n` }
|
|
951
|
+
if (first === "help") return { kind: "output", stdout: renderHelp() }
|
|
952
|
+
|
|
953
|
+
const row = first === undefined ? undefined : commandByToken(first)
|
|
954
|
+
|
|
955
|
+
// `gtd judge answer` — the CLI's one two-level verb. The NORMAL path is
|
|
956
|
+
// "answer" arriving as `positionals[1]`, a second token after "judge" —
|
|
957
|
+
// detected below via `judgeAnswer`, not through a second dispatchable
|
|
958
|
+
// `COMMAND_ROWS` row: the `"judge answer"`-tokened row above exists for
|
|
959
|
+
// `renderHelp()`'s two-line entry. It CAN also be matched by
|
|
960
|
+
// `commandByToken` directly, but only if a caller passes the single
|
|
961
|
+
// (space-containing) argv token `"judge answer"` — an unusual shell quoting
|
|
962
|
+
// choice, not the normal two-argv-token invocation `gtd judge answer` — and
|
|
963
|
+
// `row.kind` is `"judgeAnswer"` either way, so nothing downstream branches
|
|
964
|
+
// differently between the two paths. `row`'s own arity (still "none",
|
|
965
|
+
// judge's own) applies to whatever follows "answer" below, so no bespoke
|
|
966
|
+
// `Arity` variant is needed for this one verb.
|
|
967
|
+
const judgeAnswer = row?.kind === "judge" && positionals[1] === "answer"
|
|
968
|
+
|
|
969
|
+
// The `--workflow` selector: no command at all, with `--workflow` present,
|
|
970
|
+
// resolves to the generic `start` command instead — landing and starting
|
|
971
|
+
// are different verbs, so `gtd land --workflow <name>` is NOT a synonym (it
|
|
972
|
+
// fails the scope check below instead).
|
|
973
|
+
const selectsStart = workflowPresent && first === undefined
|
|
974
|
+
const judgeRun = row?.kind === "judge" && positionals[1] === "run"
|
|
975
|
+
const kind: Command["kind"] | undefined = selectsStart
|
|
976
|
+
? "start"
|
|
977
|
+
: judgeAnswer
|
|
978
|
+
? "judgeAnswer"
|
|
979
|
+
: judgeRun
|
|
980
|
+
? "judgeRun"
|
|
981
|
+
: row?.kind
|
|
982
|
+
|
|
983
|
+
if (row === undefined && !selectsStart) {
|
|
984
|
+
// No dispatchable row resolved (missing/unknown command) — a scoped flag
|
|
985
|
+
// used here (e.g. `--var` with no `--workflow`) is a more specific error
|
|
986
|
+
// than "missing command"/"unknown command", so it takes priority (mirrors
|
|
987
|
+
// the old parser, which validated --workflow/--var/--cost/--model ahead of
|
|
988
|
+
// subcommand resolution).
|
|
989
|
+
const violation = scopeViolation(undefined, present)
|
|
990
|
+
if (violation !== undefined) return usagePlan(violation, jsonSeen)
|
|
991
|
+
if (first === undefined) {
|
|
992
|
+
return usagePlan(
|
|
993
|
+
"gtd: missing command — gtd decides and prints, a driver executes; " +
|
|
994
|
+
"run `gtd install`, or copy one from " +
|
|
995
|
+
"https://github.com/pmelab/gtd/blob/main/docs/driver.md's " +
|
|
996
|
+
'"A complete minimal driver" section and run that, or see usage ' +
|
|
997
|
+
"above (`gtd --help`)",
|
|
998
|
+
jsonSeen,
|
|
999
|
+
renderHelp(),
|
|
1000
|
+
)
|
|
1001
|
+
}
|
|
1002
|
+
return usagePlan(`unknown command '${first}'`, jsonSeen)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// From here, `kind` is a genuine `Command["kind"]`. `judgeAnswer` consumes
|
|
1006
|
+
// TWO leading positionals ("judge" and "answer"), everything else one.
|
|
1007
|
+
const restPositionals =
|
|
1008
|
+
first === undefined ? positionals : positionals.slice(judgeAnswer || judgeRun ? 2 : 1)
|
|
1009
|
+
|
|
1010
|
+
if (kind === "start" && first === undefined) {
|
|
1011
|
+
if (restPositionals.length > 0) {
|
|
1012
|
+
return usagePlan(
|
|
1013
|
+
`gtd: too many arguments — expected none, got: ${restPositionals.join(", ")}`,
|
|
1014
|
+
jsonSeen,
|
|
1015
|
+
)
|
|
1016
|
+
}
|
|
1017
|
+
} else {
|
|
1018
|
+
// `kind === "start"` is unreachable here: `selectsStart` only fires when
|
|
1019
|
+
// `first === undefined`, which the `if` branch above already handled.
|
|
1020
|
+
const bareCheck =
|
|
1021
|
+
kind === "check" && restPositionals.length === 0 && present.has("--open-threads")
|
|
1022
|
+
const arityMsg = bareCheck ? undefined : arityError(first!, restPositionals, row!.arity)
|
|
1023
|
+
if (arityMsg !== undefined) return usagePlan(arityMsg, jsonSeen)
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const violation = scopeViolation(kind, present)
|
|
1027
|
+
if (violation !== undefined) return usagePlan(violation, jsonSeen)
|
|
1028
|
+
|
|
1029
|
+
const conflict = conflictViolation(present)
|
|
1030
|
+
if (conflict !== undefined) return usagePlan(conflict, jsonSeen)
|
|
1031
|
+
|
|
1032
|
+
const decoded = decodeFlags(byFlag)
|
|
1033
|
+
if (Either.isLeft(decoded)) return usagePlan(decoded.left, jsonSeen)
|
|
1034
|
+
const bag = decoded.right as {
|
|
1035
|
+
readonly "--json"?: string
|
|
1036
|
+
readonly "--port"?: number
|
|
1037
|
+
readonly "--cost"?: number
|
|
1038
|
+
readonly "--model"?: string
|
|
1039
|
+
readonly "--var"?: Readonly<Record<string, string>>
|
|
1040
|
+
readonly "--open-questions"?: boolean
|
|
1041
|
+
readonly "--open-threads"?: boolean
|
|
1042
|
+
readonly "--host"?: string
|
|
1043
|
+
readonly "--self-signed"?: boolean
|
|
1044
|
+
readonly "--dev"?: boolean
|
|
1045
|
+
readonly "--provider"?: "fixed" | "jev" | "llm"
|
|
1046
|
+
readonly "--answers"?: string
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// `present.has("--json")` alone can't distinguish bare `--json` (no value
|
|
1050
|
+
// decoded) from `--json=<path>` (a value in the bag) — see `decodeFlags`'s
|
|
1051
|
+
// optional-arity skip above.
|
|
1052
|
+
const json: JsonMode = !present.has("--json")
|
|
1053
|
+
? { kind: "off" }
|
|
1054
|
+
: bag["--json"] !== undefined
|
|
1055
|
+
? { kind: "select", path: bag["--json"] }
|
|
1056
|
+
: { kind: "document" }
|
|
1057
|
+
const verbose = present.has("--verbose")
|
|
1058
|
+
|
|
1059
|
+
if (kind === "land") {
|
|
1060
|
+
if (bag["--model"] !== undefined && bag["--cost"] === undefined) {
|
|
1061
|
+
return usagePlan(
|
|
1062
|
+
"gtd: --model requires --cost — it tags the recorded cost with the model that ran",
|
|
1063
|
+
jsonSeen,
|
|
1064
|
+
)
|
|
1065
|
+
}
|
|
1066
|
+
return { kind: "command", command: buildLandCommand(bag), json, verbose }
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (kind === "start") {
|
|
1070
|
+
const label = `gtd --workflow ${workflowRaw}`
|
|
1071
|
+
return {
|
|
1072
|
+
kind: "command",
|
|
1073
|
+
command: {
|
|
1074
|
+
kind: "start",
|
|
1075
|
+
actor: "human",
|
|
1076
|
+
workflow: workflowRaw!,
|
|
1077
|
+
vars: bag["--var"] ?? {},
|
|
1078
|
+
label,
|
|
1079
|
+
},
|
|
1080
|
+
json,
|
|
1081
|
+
verbose,
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (kind === "door") {
|
|
1086
|
+
return {
|
|
1087
|
+
kind: "command",
|
|
1088
|
+
command: { kind: "door", name: restPositionals[0]!, args: restPositionals.slice(1) },
|
|
1089
|
+
json,
|
|
1090
|
+
verbose,
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (kind === "ui") {
|
|
1095
|
+
return {
|
|
1096
|
+
kind: "command",
|
|
1097
|
+
command: {
|
|
1098
|
+
kind: "ui",
|
|
1099
|
+
...(bag["--host"] !== undefined ? { host: bag["--host"] } : {}),
|
|
1100
|
+
...(bag["--port"] !== undefined ? { port: bag["--port"] } : {}),
|
|
1101
|
+
selfSigned: bag["--self-signed"] ?? false,
|
|
1102
|
+
dev: bag["--dev"] ?? false,
|
|
1103
|
+
},
|
|
1104
|
+
json,
|
|
1105
|
+
verbose,
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (kind === "check") {
|
|
1110
|
+
return {
|
|
1111
|
+
kind: "command",
|
|
1112
|
+
command: {
|
|
1113
|
+
kind: "check",
|
|
1114
|
+
...(restPositionals[0] !== undefined ? { mode: restPositionals[0] } : {}),
|
|
1115
|
+
...(restPositionals[1] !== undefined ? { file: restPositionals[1] } : {}),
|
|
1116
|
+
...(bag["--open-questions"] !== undefined
|
|
1117
|
+
? { openQuestions: bag["--open-questions"] }
|
|
1118
|
+
: {}),
|
|
1119
|
+
...(bag["--open-threads"] !== undefined ? { openThreads: bag["--open-threads"] } : {}),
|
|
1120
|
+
},
|
|
1121
|
+
json,
|
|
1122
|
+
verbose,
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (kind === "judgeRun") {
|
|
1127
|
+
const provider = bag["--provider"]
|
|
1128
|
+
if (provider !== "fixed" && bag["--answers"] !== undefined) {
|
|
1129
|
+
return usagePlan("gtd judge run: --answers is only valid with --provider fixed", jsonSeen)
|
|
1130
|
+
}
|
|
1131
|
+
if ((provider === "fixed" || provider === "jev") && bag["--model"] !== undefined) {
|
|
1132
|
+
return usagePlan(
|
|
1133
|
+
"gtd judge run: --model is only valid with --provider llm (or when it is auto-selected)",
|
|
1134
|
+
jsonSeen,
|
|
1135
|
+
)
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
kind: "command",
|
|
1139
|
+
command: {
|
|
1140
|
+
kind: "judgeRun",
|
|
1141
|
+
...(provider !== undefined ? { provider } : {}),
|
|
1142
|
+
...(bag["--answers"] !== undefined ? { answers: bag["--answers"] } : {}),
|
|
1143
|
+
...(bag["--model"] !== undefined ? { model: bag["--model"] } : {}),
|
|
1144
|
+
},
|
|
1145
|
+
json,
|
|
1146
|
+
verbose,
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (kind === "uncheck") {
|
|
1151
|
+
return {
|
|
1152
|
+
kind: "command",
|
|
1153
|
+
command: { kind: "uncheck", file: restPositionals[0]! },
|
|
1154
|
+
json,
|
|
1155
|
+
verbose,
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Every other kind carries no extra fields.
|
|
1160
|
+
const command: Command = {
|
|
1161
|
+
kind: kind as
|
|
1162
|
+
| "lsp"
|
|
1163
|
+
| "abandon"
|
|
1164
|
+
| "restore"
|
|
1165
|
+
| "next"
|
|
1166
|
+
| "validate"
|
|
1167
|
+
| "install"
|
|
1168
|
+
| "loop"
|
|
1169
|
+
| "doors"
|
|
1170
|
+
| "summary"
|
|
1171
|
+
| "base"
|
|
1172
|
+
| "judge"
|
|
1173
|
+
| "judgeAnswer",
|
|
1174
|
+
}
|
|
1175
|
+
return { kind: "command", command, json, verbose }
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ---------------------------------------------------------------------------
|
|
1179
|
+
// runCli
|
|
1180
|
+
// ---------------------------------------------------------------------------
|
|
1181
|
+
|
|
1182
|
+
export interface CliIo {
|
|
1183
|
+
readonly stdout: (chunk: string) => void
|
|
1184
|
+
readonly stderr: (chunk: string) => void
|
|
1185
|
+
readonly exit: (code: number) => void
|
|
1186
|
+
/**
|
|
1187
|
+
* Deferred thunk — a layer is built only for a resolved `Command`, never
|
|
1188
|
+
* for `--version`/`--help`/a usage error. `verbose` (`--verbose`/`-v`,
|
|
1189
|
+
* decoded by `parseArgv`) gates the `Narrator` this builds — a no-op
|
|
1190
|
+
* writer when `false`.
|
|
1191
|
+
*/
|
|
1192
|
+
readonly layers: (verbose: boolean) => Layer.Layer<CommandRequirements>
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// The one write-to-stderr primitive nodeCliIo uses for both `stderr` (errors)
|
|
1196
|
+
// and `layers`'s `Narrator` (narration) — same sink, so a real invocation's
|
|
1197
|
+
// narration and its remediation interleave on the same fd exactly as
|
|
1198
|
+
// written.
|
|
1199
|
+
const writeStderr = (chunk: string): void => {
|
|
1200
|
+
process.stderr.write(chunk)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
export type LiveToolCommands = Layer.Layer<
|
|
1204
|
+
ToolCommands,
|
|
1205
|
+
never,
|
|
1206
|
+
Exclude<CommandRequirements, ToolCommands> | NodeContext.NodeContext
|
|
1207
|
+
>
|
|
1208
|
+
|
|
1209
|
+
export const nodeCliIo = (tools: LiveToolCommands): CliIo => ({
|
|
1210
|
+
// `process.stdout.write` is asynchronous whenever stdout is a pipe or
|
|
1211
|
+
// socket — passing a completion callback (and never force-closing, see
|
|
1212
|
+
// `exit` below) keeps a queued remainder from being discarded, so a large
|
|
1213
|
+
// artifact still reaches a slow reader even after a non-zero exit.
|
|
1214
|
+
stdout: (chunk) => {
|
|
1215
|
+
process.stdout.write(chunk, () => {})
|
|
1216
|
+
},
|
|
1217
|
+
stderr: writeStderr,
|
|
1218
|
+
exit: (code) => {
|
|
1219
|
+
// Never `process.exit(code)`: that tears the process down out from under
|
|
1220
|
+
// an undrained `stdout.write` above and truncates the artifact mid-pipe.
|
|
1221
|
+
// Setting `exitCode` lets the event loop run to completion — draining
|
|
1222
|
+
// any queued write — before Node exits gracefully with this code.
|
|
1223
|
+
process.exitCode = code
|
|
1224
|
+
},
|
|
1225
|
+
// A Layer is a lazy description, not a running service — building this
|
|
1226
|
+
// object touches no filesystem/git; only `Effect.provide` running the
|
|
1227
|
+
// result does. `runCli` calls this thunk exactly when `plan.kind ===
|
|
1228
|
+
// "command"`, so --version/--help/a usage error never provoke it.
|
|
1229
|
+
layers: (verbose) =>
|
|
1230
|
+
// Dependency order matters: Workspace.Live needs GitService, both need
|
|
1231
|
+
// Host, and GitService needs NodeContext's CommandExecutor. Each
|
|
1232
|
+
// `provideMerge` satisfies the layers above it AND stays in the output.
|
|
1233
|
+
tools.pipe(
|
|
1234
|
+
Layer.provideMerge(
|
|
1235
|
+
Layer.mergeAll(
|
|
1236
|
+
ConfigService.Live,
|
|
1237
|
+
ConfigDiscovery.Live,
|
|
1238
|
+
Workspace.Live,
|
|
1239
|
+
Narrator.layer(writeStderr, verbose),
|
|
1240
|
+
CommandRunner.Live,
|
|
1241
|
+
).pipe(
|
|
1242
|
+
Layer.provideMerge(GitService.Live),
|
|
1243
|
+
Layer.provideMerge(Host.Live),
|
|
1244
|
+
Layer.provideMerge(NodeContext.layer),
|
|
1245
|
+
),
|
|
1246
|
+
),
|
|
1247
|
+
),
|
|
1248
|
+
})
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Normalizes an artifact's trailing newlines to exactly one — an emitted
|
|
1252
|
+
* script ends with none, plain text already ends with one, so this is the
|
|
1253
|
+
* single place both become uniform. A byte-empty artifact stays byte-empty
|
|
1254
|
+
* (never becomes a lone `\n`); several trailing newlines collapse to one.
|
|
1255
|
+
*/
|
|
1256
|
+
export const normalizeTrailingNewline = (artifact: string): string =>
|
|
1257
|
+
artifact.length === 0 ? artifact : `${artifact.replace(/\n+$/, "")}\n`
|
|
1258
|
+
|
|
1259
|
+
const bufferedArtifactOut = (io: CliIo): ArtifactOut => {
|
|
1260
|
+
let buffer = ""
|
|
1261
|
+
return {
|
|
1262
|
+
write: (chunk) => {
|
|
1263
|
+
buffer += chunk
|
|
1264
|
+
},
|
|
1265
|
+
flush: () => {
|
|
1266
|
+
if (buffer.length > 0) io.stdout(normalizeTrailingNewline(buffer))
|
|
1267
|
+
buffer = ""
|
|
1268
|
+
},
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* The single envelope writer: `{state:"error",prompt}` on **stderr** under
|
|
1274
|
+
* `--json`, `renderFailure` on stderr always, exit `EXIT_RUNTIME_ERROR`.
|
|
1275
|
+
* stdout is never touched here — the command's `ArtifactOut` buffer was never
|
|
1276
|
+
* flushed, so a `--json` driver reading stdout on a failed run reads nothing
|
|
1277
|
+
* — it must read stderr or the exit code instead. `Effect.sandbox` means this also
|
|
1278
|
+
* fires for a DEFECT, not just a typed error. Unreached by an ordinary usage
|
|
1279
|
+
* error (an unknown flag, bad arity, a scope violation) — those never build a
|
|
1280
|
+
* layer at all. Two exceptions map to `EXIT_USAGE_ERROR` instead, below:
|
|
1281
|
+
* `SelectorUsageError` (an unknown `--json=<path>` selector can only be
|
|
1282
|
+
* judged after the layer is built and the fields object it's reduced
|
|
1283
|
+
* against is fully resolved, so it fails HERE rather than in `parseArgv`)
|
|
1284
|
+
* and `GtdUsageError` (`gtd ui` refusing to start on a step it cannot
|
|
1285
|
+
* render — see `Commentary.ts`).
|
|
1286
|
+
*/
|
|
1287
|
+
const report =
|
|
1288
|
+
(io: CliIo, json: boolean) =>
|
|
1289
|
+
(cause: Cause.Cause<Error>): Effect.Effect<void> =>
|
|
1290
|
+
Effect.sync(() => {
|
|
1291
|
+
const error = Cause.squash(cause)
|
|
1292
|
+
if (json) {
|
|
1293
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1294
|
+
io.stderr(`${JSON.stringify({ state: "error", prompt: message })}\n`)
|
|
1295
|
+
}
|
|
1296
|
+
io.stderr(`${renderFailure(error)}\n`)
|
|
1297
|
+
io.exit(
|
|
1298
|
+
error instanceof SelectorUsageError || error instanceof GtdUsageError
|
|
1299
|
+
? EXIT_USAGE_ERROR
|
|
1300
|
+
: EXIT_RUNTIME_ERROR,
|
|
1301
|
+
)
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1304
|
+
export const runCli = (argv: readonly string[], io: CliIo): Effect.Effect<void, Error> => {
|
|
1305
|
+
const plan = parseArgv(argv)
|
|
1306
|
+
|
|
1307
|
+
if (plan.kind === "output") {
|
|
1308
|
+
return Effect.sync(() => io.stdout(plan.stdout))
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
if (plan.kind === "usage") {
|
|
1312
|
+
return Effect.sync(() => {
|
|
1313
|
+
// The rendered help text (missing-command only) and the `--json`
|
|
1314
|
+
// envelope both move to stderr — stdout stays byte-empty on a usage
|
|
1315
|
+
// error exactly like every other failure surface.
|
|
1316
|
+
if (plan.stdout.length > 0) io.stderr(plan.stdout)
|
|
1317
|
+
if (plan.json) {
|
|
1318
|
+
io.stderr(`${JSON.stringify({ state: "error", prompt: plan.message })}\n`)
|
|
1319
|
+
}
|
|
1320
|
+
io.stderr(`${renderFailure(plan.message)}\n`)
|
|
1321
|
+
io.exit(EXIT_USAGE_ERROR)
|
|
1322
|
+
})
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
const out = bufferedArtifactOut(io)
|
|
1326
|
+
return runCommand(plan.command, plan.json, out).pipe(
|
|
1327
|
+
// `flush()` fires here, on success, BEFORE `io.exit` — the one point
|
|
1328
|
+
// where the whole buffered artifact is known-complete and safe to hand
|
|
1329
|
+
// to stdout (a failure discards the buffer instead — see
|
|
1330
|
+
// `bufferedArtifactOut`).
|
|
1331
|
+
Effect.map(() => {
|
|
1332
|
+
out.flush()
|
|
1333
|
+
io.exit(EXIT_OK)
|
|
1334
|
+
}),
|
|
1335
|
+
Effect.provide(io.layers(plan.verbose)),
|
|
1336
|
+
Effect.sandbox,
|
|
1337
|
+
// `sandbox` moves EVERY failure mode — a typed error, a defect, an
|
|
1338
|
+
// interruption — into this handler's `cause`. An interrupt-only cause
|
|
1339
|
+
// (Ctrl-C on `ui`/`lsp`'s blocking `Effect.never`) is passed
|
|
1340
|
+
// through untouched via `failCause` so `NodeRuntime.runMain` still sees a
|
|
1341
|
+
// real interruption; everything else gets the envelope.
|
|
1342
|
+
Effect.catchAll((cause) =>
|
|
1343
|
+
Cause.isInterruptedOnly(cause)
|
|
1344
|
+
? Effect.failCause(cause)
|
|
1345
|
+
: report(io, plan.json.kind !== "off")(cause),
|
|
1346
|
+
),
|
|
1347
|
+
)
|
|
1348
|
+
}
|