@pmelab/gtd 13.0.0 → 14.0.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/README.md +37 -24
- package/dist/gtd.bundle.mjs +3569 -11747
- package/package.json +8 -3
- package/schema.json +5 -287
- package/src/flows/helpers.ts +93 -0
- package/src/flows/index.ts +3 -0
- package/src/flows/runtime.ts +295 -0
- package/src/flows/scripts.ts +116 -0
- package/src/workflows/health.ts +126 -0
- package/src/workflows/index.ts +1 -0
- package/src/workflows/packages.ts +93 -0
- package/src/workflows/planning.ts +99 -0
- package/src/workflows/prose.ts +200 -0
- package/src/workflows/review.ts +169 -0
- package/src/workflows/steps.ts +200 -0
- package/src/workflows/text.fixture.ts +38 -0
- package/src/workflows/text.test.ts +37 -0
- package/src/workflows/text.ts +682 -0
- package/src/workflows/unified.ts +128 -0
- package/src/workflows/vars.ts +23 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// The authoring surface of a gtd workflow. Every function here is a thin
|
|
2
|
+
// facade over the replay context the engine installs on `globalThis` — keyed by
|
|
3
|
+
// a registered symbol, not a module-local variable, so a copy of this file
|
|
4
|
+
// loaded through a user's `gtd.config.ts` (jiti) and the copy bundled into the
|
|
5
|
+
// gtd binary drive the same replay.
|
|
6
|
+
|
|
7
|
+
/** A steering-file declaration shared by the steps that rest on one. */
|
|
8
|
+
export interface SteeringOptions {
|
|
9
|
+
/** The steering file — a repository path under `.gtd/`. */
|
|
10
|
+
readonly file?: string | undefined
|
|
11
|
+
/** The steering file's mode — a built-in name or a `modes:` entry. Requires `file`. */
|
|
12
|
+
readonly mode?: string | undefined
|
|
13
|
+
/** Display name for drivers and viewers. */
|
|
14
|
+
readonly label?: string | undefined
|
|
15
|
+
/** The commit this step reviews changes since — what `gtd base` prints while it rests here. */
|
|
16
|
+
readonly base?: string | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AgentOptions extends SteeringOptions {
|
|
20
|
+
readonly model?: string | undefined
|
|
21
|
+
readonly system?: string | undefined
|
|
22
|
+
/**
|
|
23
|
+
* A turn that changes nothing completes the step. Without this an empty turn
|
|
24
|
+
* is an attempt: recorded, but the process stays at the step (a stall).
|
|
25
|
+
*/
|
|
26
|
+
readonly allowEmpty?: boolean | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HumanOptions extends SteeringOptions {
|
|
30
|
+
readonly message?: string | undefined
|
|
31
|
+
/**
|
|
32
|
+
* A landing that changes nothing completes the gate — accepting as-is.
|
|
33
|
+
* Without this a clean landing is a no-op: the gate waits for a change.
|
|
34
|
+
*/
|
|
35
|
+
readonly acceptClean?: boolean | undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RunOptions extends SteeringOptions {}
|
|
39
|
+
|
|
40
|
+
export type JudgePrimitive = "noul" | "choice" | "score"
|
|
41
|
+
|
|
42
|
+
export interface JudgeQuestion {
|
|
43
|
+
readonly id: string
|
|
44
|
+
readonly primitive: JudgePrimitive
|
|
45
|
+
readonly instructions: string
|
|
46
|
+
readonly criteria: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface JudgeSpec {
|
|
50
|
+
readonly questions: readonly JudgeQuestion[]
|
|
51
|
+
/** What the judge sees. The `judgeBudgetBytes` budget is split evenly across the keys; a value over its share keeps its end. */
|
|
52
|
+
readonly evidence: Readonly<Record<string, string>>
|
|
53
|
+
/** The human-facing message a driver unaware of judge gates shows. */
|
|
54
|
+
readonly message?: string | undefined
|
|
55
|
+
readonly label?: string | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One recorded answer. A `noul`'s boolean reads as `"yes"`/`"no"`, a `score` as its decimal string. */
|
|
59
|
+
export interface JudgeAnswer {
|
|
60
|
+
readonly answer: string
|
|
61
|
+
readonly p: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface Judgment {
|
|
65
|
+
/** Every question's answer, `undefined` when the turn landed without a verdict for it. */
|
|
66
|
+
readonly answers: Readonly<Record<string, JudgeAnswer | undefined>>
|
|
67
|
+
/** The evidence keys the budget cut. */
|
|
68
|
+
readonly truncated: readonly string[]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One path the last step changed. */
|
|
72
|
+
export interface Change {
|
|
73
|
+
readonly path: string
|
|
74
|
+
readonly status: "added" | "modified" | "deleted"
|
|
75
|
+
/** The content before the step, `undefined` when the step added the path. */
|
|
76
|
+
readonly before: string | undefined
|
|
77
|
+
/** The content the step left, `undefined` when the step deleted the path. */
|
|
78
|
+
readonly after: string | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface Changes extends ReadonlyArray<Change> {
|
|
82
|
+
readonly paths: readonly string[]
|
|
83
|
+
readonly get: (path: string) => Change | undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ScopeOptions {
|
|
87
|
+
/** Prefixes every step name inside, and so names their memory scope. */
|
|
88
|
+
readonly name?: string | undefined
|
|
89
|
+
/** The model every agent step inside runs with, unless it sets its own. */
|
|
90
|
+
readonly model?: string | undefined
|
|
91
|
+
/** The system prompt every agent step inside runs with, unless it sets its own. */
|
|
92
|
+
readonly system?: string | undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type StepRequest =
|
|
96
|
+
| {
|
|
97
|
+
readonly kind: "agent"
|
|
98
|
+
readonly name: string
|
|
99
|
+
readonly prompt: string
|
|
100
|
+
readonly options: AgentOptions
|
|
101
|
+
}
|
|
102
|
+
| { readonly kind: "human"; readonly name: string; readonly options: HumanOptions }
|
|
103
|
+
| {
|
|
104
|
+
readonly kind: "run"
|
|
105
|
+
readonly name: string
|
|
106
|
+
readonly body: string
|
|
107
|
+
readonly options: RunOptions
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
readonly kind: "judge"
|
|
111
|
+
readonly name: string
|
|
112
|
+
readonly questions: readonly JudgeQuestion[]
|
|
113
|
+
readonly evidence: Readonly<Record<string, string>>
|
|
114
|
+
readonly options: SteeringOptions & { readonly message?: string | undefined }
|
|
115
|
+
}
|
|
116
|
+
| { readonly kind: "restart" }
|
|
117
|
+
|
|
118
|
+
/** The engine side of the facade — see `installContext`. */
|
|
119
|
+
export interface FlowContext {
|
|
120
|
+
readonly step: (request: StepRequest) => Promise<unknown>
|
|
121
|
+
readonly refuse: (message: string) => never
|
|
122
|
+
readonly pushScope: (scope: ScopeOptions) => void
|
|
123
|
+
readonly popScope: () => void
|
|
124
|
+
readonly read: (path: string) => string | undefined
|
|
125
|
+
readonly glob: (pattern: string) => readonly string[]
|
|
126
|
+
/** The last step's changes, content read on demand. */
|
|
127
|
+
readonly changes: () => readonly Change[]
|
|
128
|
+
readonly matches: (path: string, pattern: string) => boolean
|
|
129
|
+
readonly sections: (text: string) => readonly string[]
|
|
130
|
+
readonly sectionBodies: (text: string) => readonly Section[]
|
|
131
|
+
readonly openQuestions: (text: string) => readonly OpenQuestion[]
|
|
132
|
+
readonly vars: Readonly<Record<string, string>>
|
|
133
|
+
readonly head: () => string
|
|
134
|
+
readonly start: () => string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const CONTEXT_KEY = Symbol.for("@pmelab/gtd/flow-context")
|
|
138
|
+
|
|
139
|
+
type ContextHolder = { [CONTEXT_KEY]?: FlowContext | undefined }
|
|
140
|
+
|
|
141
|
+
/** Engine-only: install the replay context a flow run reads. */
|
|
142
|
+
export const installContext = (context: FlowContext | undefined): void => {
|
|
143
|
+
;(globalThis as ContextHolder)[CONTEXT_KEY] = context
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ctx = (): FlowContext => {
|
|
147
|
+
const context = (globalThis as ContextHolder)[CONTEXT_KEY]
|
|
148
|
+
if (context === undefined) {
|
|
149
|
+
throw new Error("gtd: a workflow step or helper was called outside a gtd replay")
|
|
150
|
+
}
|
|
151
|
+
return context
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Steps ───────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/** An agent turn: `prompt` is emitted, the driver lands the diff. */
|
|
157
|
+
export const agent = (name: string, prompt: string, options: AgentOptions = {}): Promise<void> =>
|
|
158
|
+
ctx().step({ kind: "agent", name, prompt, options }) as Promise<void>
|
|
159
|
+
|
|
160
|
+
/** A human gate: the process rests until a person lands a turn. */
|
|
161
|
+
export const human = (name: string, options: HumanOptions = {}): Promise<void> =>
|
|
162
|
+
ctx().step({ kind: "human", name, options }) as Promise<void>
|
|
163
|
+
|
|
164
|
+
/** A check: `body` runs at the edge and its effect on the tree is committed. */
|
|
165
|
+
export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
|
|
166
|
+
ctx().step({ kind: "run", name, body, options }) as Promise<void>
|
|
167
|
+
|
|
168
|
+
/** A judge gate: resolves to what the judge answered, for the flow to decide on. */
|
|
169
|
+
export const judge = (name: string, spec: JudgeSpec): Promise<Judgment> => {
|
|
170
|
+
const { questions, evidence, ...options } = spec
|
|
171
|
+
return ctx().step({ kind: "judge", name, questions, evidence, options }) as Promise<Judgment>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** End the episode from any depth. The next episode starts the flow afresh. */
|
|
175
|
+
export const restart = (): Promise<never> => ctx().step({ kind: "restart" }) as Promise<never>
|
|
176
|
+
|
|
177
|
+
/** Refuse the pending turn: nothing lands, the process stays where it rests. */
|
|
178
|
+
export const refuse = (message: string): never => ctx().refuse(message)
|
|
179
|
+
|
|
180
|
+
// ── Composition ─────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run `fn` inside a scope: `scope("build", fn)` prefixes every step name with
|
|
184
|
+
* `build.` (also their memory scope); an object may add the model and system
|
|
185
|
+
* prompt its agent steps run with.
|
|
186
|
+
*/
|
|
187
|
+
export const scope = async <T>(scope: string | ScopeOptions, fn: () => Promise<T>): Promise<T> => {
|
|
188
|
+
const context = ctx()
|
|
189
|
+
context.pushScope(typeof scope === "string" ? { name: scope } : scope)
|
|
190
|
+
try {
|
|
191
|
+
return await fn()
|
|
192
|
+
} finally {
|
|
193
|
+
context.popScope()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Reads of the commit replay stands on ────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export const read = (path: string): string | undefined => ctx().read(path)
|
|
200
|
+
|
|
201
|
+
/** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
|
|
202
|
+
export const glob = (pattern: string): readonly string[] => ctx().glob(pattern)
|
|
203
|
+
|
|
204
|
+
/** What the last step changed, optionally only the paths matching a glob. */
|
|
205
|
+
export const changes = (pattern?: string): Changes => {
|
|
206
|
+
const context = ctx()
|
|
207
|
+
const all = context.changes()
|
|
208
|
+
const list = pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern))
|
|
209
|
+
return Object.freeze(
|
|
210
|
+
Object.assign([...list], {
|
|
211
|
+
paths: list.map((c) => c.path),
|
|
212
|
+
get: (path: string) => list.find((c) => c.path === path),
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The commit the process stands on at this point of the flow. */
|
|
218
|
+
export const head = (): string => ctx().head()
|
|
219
|
+
|
|
220
|
+
/** The process's diff base: the commit before it began, or the base `gtd --entry` fixed. */
|
|
221
|
+
export const start = (): string => ctx().start()
|
|
222
|
+
|
|
223
|
+
/** The merged workflow variables. */
|
|
224
|
+
export const vars: Readonly<Record<string, string>> = new Proxy(
|
|
225
|
+
{},
|
|
226
|
+
{
|
|
227
|
+
get: (_target, key) => (typeof key === "string" ? ctx().vars[key] : undefined),
|
|
228
|
+
has: (_target, key) => typeof key === "string" && key in ctx().vars,
|
|
229
|
+
ownKeys: () => Object.keys(ctx().vars),
|
|
230
|
+
getOwnPropertyDescriptor: (_target, key) =>
|
|
231
|
+
typeof key === "string" && key in ctx().vars
|
|
232
|
+
? { enumerable: true, configurable: true, value: ctx().vars[key] }
|
|
233
|
+
: undefined,
|
|
234
|
+
},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
// ── Text utilities ──────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/** The top-level `## ` heading texts of markdown `text`. */
|
|
240
|
+
export const sections = (text: string): readonly string[] => ctx().sections(text)
|
|
241
|
+
|
|
242
|
+
export interface Section {
|
|
243
|
+
readonly title: string
|
|
244
|
+
/** The section's own markdown, its heading line included. */
|
|
245
|
+
readonly body: string
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The top-level `## ` sections of markdown `text`, each with its own markdown. */
|
|
249
|
+
export const sectionBodies = (text: string): readonly Section[] => ctx().sectionBodies(text)
|
|
250
|
+
|
|
251
|
+
export interface OpenQuestion {
|
|
252
|
+
readonly question: string
|
|
253
|
+
/** The 1-based line of the question's heading. */
|
|
254
|
+
readonly line: number
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The qa-format questions in `text` that are still unanswered. */
|
|
258
|
+
export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().openQuestions(text)
|
|
259
|
+
|
|
260
|
+
// ── The workflow ────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export interface FlowArgs {
|
|
263
|
+
/** The name `gtd --entry <name>` started the process with; `undefined` for an ordinary start. */
|
|
264
|
+
readonly entry: string | undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A workflow's one flow. A flow that never reads `entry` accepts no
|
|
269
|
+
* `--entry`; one that does decides for itself which names it honours,
|
|
270
|
+
* `refuse()`-ing the rest.
|
|
271
|
+
*/
|
|
272
|
+
export type Flow = (args: FlowArgs) => Promise<void>
|
|
273
|
+
|
|
274
|
+
export interface SummaryContext {
|
|
275
|
+
readonly entryCommit: string
|
|
276
|
+
readonly processBase: string
|
|
277
|
+
readonly processTip: string
|
|
278
|
+
readonly humanCommits: readonly { readonly hash: string; readonly state: string }[]
|
|
279
|
+
readonly processCost: number
|
|
280
|
+
readonly processCostByModel: readonly { readonly model: string; readonly cost: number }[]
|
|
281
|
+
readonly vars: Readonly<Record<string, string>>
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** `gtd summary`'s prompt — a workflow module's optional `summary` export. */
|
|
285
|
+
export type Summary = (context: SummaryContext) => string
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A workflow module's optional `base` export: the commitish that fixes the
|
|
289
|
+
* diff base of a process `gtd --entry <entry>` starts, or `undefined` for
|
|
290
|
+
* none. Runs when the process is entered, with the vars `--var` sets.
|
|
291
|
+
*/
|
|
292
|
+
export type EntryBase = (
|
|
293
|
+
entry: string,
|
|
294
|
+
vars: Readonly<Record<string, string>>,
|
|
295
|
+
) => string | undefined
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The shell a step hands the driver, rendered from values the flow computed.
|
|
2
|
+
// Every script here is POSIX sh and runs from the repository root; none of
|
|
3
|
+
// them writes to git beyond the working tree.
|
|
4
|
+
|
|
5
|
+
/** POSIX single-quoting: `value` survives the shell as one word, verbatim. */
|
|
6
|
+
export const quote = (value: string): string => `'${value.replace(/'/g, "'\\''")}'`
|
|
7
|
+
|
|
8
|
+
export interface CheckScriptOptions {
|
|
9
|
+
/** Where a failing run's output goes, removed again by a passing one. */
|
|
10
|
+
readonly report: string
|
|
11
|
+
/** Written after the output, so a repeat of the same failure still changes the report. */
|
|
12
|
+
readonly stamp: string
|
|
13
|
+
/** Paths removed before the command runs. */
|
|
14
|
+
readonly sweep?: readonly string[] | undefined
|
|
15
|
+
/** Paths removed once it passes. */
|
|
16
|
+
readonly sweepOnGreen?: readonly string[] | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const removal = (paths: readonly string[]): string[] =>
|
|
20
|
+
paths.length === 0 ? [] : [`rm -rf -- ${paths.map(quote).join(" ")}`]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run `command` and keep its outcome in the tree: on failure its output (or
|
|
24
|
+
* a note that there was none) lands in `report`, on success `report` goes.
|
|
25
|
+
*/
|
|
26
|
+
export const checkScript = (command: string, options: CheckScriptOptions): string => {
|
|
27
|
+
const report = quote(options.report)
|
|
28
|
+
const output = quote(`${options.report}.output`)
|
|
29
|
+
return [
|
|
30
|
+
"#!/usr/bin/env sh",
|
|
31
|
+
"set +e",
|
|
32
|
+
...removal(options.sweep ?? []),
|
|
33
|
+
`mkdir -p "$(dirname ${report})"`,
|
|
34
|
+
// A subshell, so an `exit` inside the command ends only the command.
|
|
35
|
+
"(",
|
|
36
|
+
command,
|
|
37
|
+
`) > ${output} 2>&1`,
|
|
38
|
+
"code=$?",
|
|
39
|
+
'if [ "$code" -ne 0 ]; then',
|
|
40
|
+
` if [ -s ${output} ]; then`,
|
|
41
|
+
` mv ${output} ${report}`,
|
|
42
|
+
" else",
|
|
43
|
+
` rm -f ${output}`,
|
|
44
|
+
` printf 'the test command failed with exit code %s and produced no output.' "$code" > ${report}`,
|
|
45
|
+
" fi",
|
|
46
|
+
` printf '\\n<!-- gtd check %s -->\\n' ${quote(options.stamp)} >> ${report}`,
|
|
47
|
+
"else",
|
|
48
|
+
` rm -f ${output} ${report}`,
|
|
49
|
+
...removal(options.sweepOnGreen ?? []).map((line) => ` ${line}`),
|
|
50
|
+
"fi",
|
|
51
|
+
"",
|
|
52
|
+
].join("\n")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Reverse-apply `commit`'s changes to the working tree. A failure — the
|
|
57
|
+
* patch no longer applies — writes `failure` and git's error to `report`,
|
|
58
|
+
* since a failed revert and one with nothing to undo both leave the tree clean.
|
|
59
|
+
*/
|
|
60
|
+
export const revertScript = (commit: string, report: string, failure: string): string => {
|
|
61
|
+
const range = `${quote(`${commit}^`)} ${quote(commit)}`
|
|
62
|
+
const error = quote(`${report}.output`)
|
|
63
|
+
return [
|
|
64
|
+
"#!/usr/bin/env sh",
|
|
65
|
+
"set +e",
|
|
66
|
+
`if ! git diff --quiet ${range} --; then`,
|
|
67
|
+
` mkdir -p "$(dirname ${quote(report)})"`,
|
|
68
|
+
` git diff --binary ${range} -- | git apply -R 2> ${error}`,
|
|
69
|
+
" code=$?",
|
|
70
|
+
' if [ "$code" -ne 0 ]; then',
|
|
71
|
+
" {",
|
|
72
|
+
` printf '%s\\n\\n' ${quote(failure)}`,
|
|
73
|
+
` if [ -s ${error} ]; then cat ${error}; else printf 'Reverting it exited %s and produced no output.\\n' "$code"; fi`,
|
|
74
|
+
` } > ${quote(report)}`,
|
|
75
|
+
" fi",
|
|
76
|
+
` rm -f ${error}`,
|
|
77
|
+
"fi",
|
|
78
|
+
"",
|
|
79
|
+
].join("\n")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Undo the edits one commit made to `paths`: each goes back to its content
|
|
84
|
+
* before `commit` — or away, when `commit` added it — but only while it is
|
|
85
|
+
* still exactly as `commit` left it. A path changed since is left alone.
|
|
86
|
+
*/
|
|
87
|
+
export const restoreScript = (
|
|
88
|
+
commit: string,
|
|
89
|
+
paths: { readonly restore: readonly string[]; readonly remove: readonly string[] },
|
|
90
|
+
): string => {
|
|
91
|
+
const unchanged = (path: string): string => `git diff --quiet ${quote(commit)} -- ${quote(path)}`
|
|
92
|
+
return [
|
|
93
|
+
"#!/usr/bin/env sh",
|
|
94
|
+
"set +e",
|
|
95
|
+
...paths.restore.map(
|
|
96
|
+
(path) =>
|
|
97
|
+
`${unchanged(path)} && git restore --source=${quote(`${commit}~1`)} --worktree -- ${quote(path)}`,
|
|
98
|
+
),
|
|
99
|
+
...paths.remove.map((path) => `${unchanged(path)} && rm -f -- ${quote(path)}`),
|
|
100
|
+
"",
|
|
101
|
+
].join("\n")
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Remove `paths` from the working tree; absent ones are fine. */
|
|
105
|
+
export const removeScript = (paths: readonly string[]): string =>
|
|
106
|
+
["#!/usr/bin/env sh", ...removal(paths), ""].join("\n")
|
|
107
|
+
|
|
108
|
+
/** Move `from` to `to`, creating `to`'s directory. */
|
|
109
|
+
export const moveScript = (from: string, to: string): string =>
|
|
110
|
+
[
|
|
111
|
+
"#!/usr/bin/env sh",
|
|
112
|
+
"set -e",
|
|
113
|
+
`mkdir -p "$(dirname ${quote(to)})"`,
|
|
114
|
+
`mv -- ${quote(from)} ${quote(to)}`,
|
|
115
|
+
"",
|
|
116
|
+
].join("\n")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answered,
|
|
3
|
+
check,
|
|
4
|
+
human,
|
|
5
|
+
judge,
|
|
6
|
+
numeric,
|
|
7
|
+
read,
|
|
8
|
+
scope,
|
|
9
|
+
vars,
|
|
10
|
+
type JudgeQuestion,
|
|
11
|
+
} from "../flows/index.js"
|
|
12
|
+
import { describeEscalation, escalate, escalationExhausted, ESCALATION, FEEDBACK } from "./steps.js"
|
|
13
|
+
import * as t from "./text.js"
|
|
14
|
+
|
|
15
|
+
/** Fix turns a run of red checks gets before it escalates. */
|
|
16
|
+
export const FIX_CAP = 3
|
|
17
|
+
|
|
18
|
+
/** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
|
|
19
|
+
export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
|
|
20
|
+
check(name, vars.testCommand ?? "", { report: FEEDBACK, label })
|
|
21
|
+
|
|
22
|
+
/** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
|
|
23
|
+
export interface EscalationCount {
|
|
24
|
+
rounds: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hand a red suite that repeated attempts could not fix to a person: an
|
|
29
|
+
* agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
|
|
30
|
+
* on, the human is told the budget is spent instead.
|
|
31
|
+
*/
|
|
32
|
+
export const escalation = async (count: EscalationCount): Promise<void> => {
|
|
33
|
+
if (count.rounds >= 2) return escalationExhausted()
|
|
34
|
+
count.rounds++
|
|
35
|
+
await describeEscalation()
|
|
36
|
+
await escalate()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
|
|
40
|
+
const stripStamp = (text: string): string => text.replace(STAMP, "\n")
|
|
41
|
+
|
|
42
|
+
/** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
|
|
43
|
+
const retryQuestion = (comparable: boolean): JudgeQuestion =>
|
|
44
|
+
comparable
|
|
45
|
+
? {
|
|
46
|
+
id: "verdict",
|
|
47
|
+
primitive: "choice",
|
|
48
|
+
instructions:
|
|
49
|
+
"Compare this round's failing check output (current) against the previous round's (previous), both in state. Classify the change.",
|
|
50
|
+
criteria:
|
|
51
|
+
"identical: the same failure restated, byte-for-byte or the same root cause — the trailing `<!-- gtd check <sha> -->` stamp is already stripped from both, but ignore it too if it ever reappears. new-failure: a materially different symptom than previous. progress: still red, but measurably closer to green (fewer failures, a later stage reached).",
|
|
52
|
+
}
|
|
53
|
+
: {
|
|
54
|
+
id: "verdict",
|
|
55
|
+
primitive: "choice",
|
|
56
|
+
instructions:
|
|
57
|
+
"state carries tailsNotComparable: one of the two check reports came back empty. There is nothing to compare here; answer from criteria alone.",
|
|
58
|
+
criteria:
|
|
59
|
+
"identical is FORBIDDEN here — answer new-failure or progress instead, defaulting to progress if genuinely unsure.",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sameFailure = async (previous: string, current: string): Promise<boolean> => {
|
|
63
|
+
const comparable = current.trim() !== "" && previous.trim() !== ""
|
|
64
|
+
const { answers, truncated } = await judge("health.judge", {
|
|
65
|
+
questions: [retryQuestion(comparable)],
|
|
66
|
+
evidence: comparable ? { current, previous } : { tailsNotComparable: "true" },
|
|
67
|
+
message: t.healthJudgeMessage(),
|
|
68
|
+
label: "Judging the retry",
|
|
69
|
+
})
|
|
70
|
+
// Evidence the budget cut can make two different reports look alike: never sameness.
|
|
71
|
+
return (
|
|
72
|
+
comparable &&
|
|
73
|
+
truncated.length === 0 &&
|
|
74
|
+
answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity))
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface HealthOptions {
|
|
79
|
+
/** Fix turns already spent before this call. */
|
|
80
|
+
readonly fixesSoFar?: number
|
|
81
|
+
/** Escalation rounds shared with other callers in the same run of red checks. */
|
|
82
|
+
readonly escalations?: EscalationCount
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
87
|
+
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
88
|
+
* identical to the previous round's.
|
|
89
|
+
*/
|
|
90
|
+
export const healthy = async (
|
|
91
|
+
fix: () => Promise<void>,
|
|
92
|
+
options: HealthOptions = {},
|
|
93
|
+
): Promise<void> => {
|
|
94
|
+
const escalations = options.escalations ?? { rounds: 0 }
|
|
95
|
+
let fixes = options.fixesSoFar ?? 0
|
|
96
|
+
let previous: string | undefined
|
|
97
|
+
for (;;) {
|
|
98
|
+
const green = await check("health.check", vars.testCommand ?? "", {
|
|
99
|
+
report: FEEDBACK,
|
|
100
|
+
label: "Running checks",
|
|
101
|
+
// Swept only on green: an unresolved analysis survives every retry.
|
|
102
|
+
sweepOnGreen: [ESCALATION],
|
|
103
|
+
})
|
|
104
|
+
if (green) {
|
|
105
|
+
escalations.rounds = 0
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const current = stripStamp(read(FEEDBACK) ?? "")
|
|
109
|
+
const stuck = previous !== undefined && (await sameFailure(previous, current))
|
|
110
|
+
previous = current
|
|
111
|
+
if (stuck || fixes >= FIX_CAP) {
|
|
112
|
+
await escalation(escalations)
|
|
113
|
+
fixes = 0
|
|
114
|
+
}
|
|
115
|
+
fixes++
|
|
116
|
+
await fix()
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Hold the process at `<name>.blocked` until the suite is green. */
|
|
121
|
+
export const gate = (name: string, message: string): Promise<void> =>
|
|
122
|
+
scope(name, async () => {
|
|
123
|
+
while (!(await baseline("check"))) {
|
|
124
|
+
await human("blocked", { message, label: "Baseline is red", file: FEEDBACK })
|
|
125
|
+
}
|
|
126
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as unified from "./unified.js"
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answered,
|
|
3
|
+
changes,
|
|
4
|
+
glob,
|
|
5
|
+
judge,
|
|
6
|
+
numeric,
|
|
7
|
+
read,
|
|
8
|
+
refuse,
|
|
9
|
+
removeScript,
|
|
10
|
+
run,
|
|
11
|
+
scope,
|
|
12
|
+
sectionBodies,
|
|
13
|
+
start,
|
|
14
|
+
vars,
|
|
15
|
+
wrote,
|
|
16
|
+
type JudgeQuestion,
|
|
17
|
+
} from "../flows/index.js"
|
|
18
|
+
import { healthy } from "./health.js"
|
|
19
|
+
import { ARCHITECTURE, build, fixSpec, fixSuite, reviewPackage, SPEC_FEEDBACK } from "./steps.js"
|
|
20
|
+
import * as t from "./text.js"
|
|
21
|
+
|
|
22
|
+
const MAX_SECTIONS = 8
|
|
23
|
+
|
|
24
|
+
const sectionQuestion = (id: string, title: string): JudgeQuestion => ({
|
|
25
|
+
id,
|
|
26
|
+
primitive: "noul",
|
|
27
|
+
instructions: `Is the requirement "${title}" already fully satisfied by the code on the range from ${start()} to the working tree?`,
|
|
28
|
+
criteria:
|
|
29
|
+
"Judge from the package markdown plus that range, read yourself. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing.",
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Review one freshly built package against its spec: a pre-judge per
|
|
34
|
+
* section, then an agent review of the sections it did not confidently
|
|
35
|
+
* clear. Resolves `true` when approved.
|
|
36
|
+
*/
|
|
37
|
+
export const specReview = async (pkg: string): Promise<boolean> => {
|
|
38
|
+
const found = sectionBodies(read(pkg) ?? "")
|
|
39
|
+
const titles = found.map((section) => section.title)
|
|
40
|
+
const judged = titles.length > 0 && titles.length <= MAX_SECTIONS
|
|
41
|
+
const evidence: Record<string, string> = {}
|
|
42
|
+
const questions: JudgeQuestion[] = []
|
|
43
|
+
if (judged) {
|
|
44
|
+
found.forEach(({ title, body }, i) => {
|
|
45
|
+
evidence[`section-${i + 1}`] = body
|
|
46
|
+
questions.push(sectionQuestion(`section-${i + 1}`, title))
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
const { answers, truncated } = await judge("spec.pre", {
|
|
50
|
+
questions,
|
|
51
|
+
evidence,
|
|
52
|
+
message: t.packagesItemSpecPreMessage(),
|
|
53
|
+
label: "Judging spec coverage",
|
|
54
|
+
})
|
|
55
|
+
// A section is cleared only by a confident yes on evidence that was not cut.
|
|
56
|
+
const clearMinP = numeric(vars.specPreJudge, Infinity)
|
|
57
|
+
const failing = titles.filter((_, i) => {
|
|
58
|
+
const id = `section-${i + 1}`
|
|
59
|
+
return !judged || truncated.includes(id) || !answered(answers[id], "yes", clearMinP)
|
|
60
|
+
})
|
|
61
|
+
if (titles.length > 0 && failing.length === 0) return true
|
|
62
|
+
await reviewPackage(pkg, failing)
|
|
63
|
+
if (wrote(SPEC_FEEDBACK)) return false
|
|
64
|
+
if (changes(SPEC_FEEDBACK).length > 0 || changes().length === 0) return true
|
|
65
|
+
return refuse(
|
|
66
|
+
"gtd land: no declared pattern matches the pending changes — write .gtd/SPEC_FEEDBACK.md to request changes, or change nothing to approve",
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
|
|
71
|
+
export const packageItem = async (pkg: string): Promise<void> => {
|
|
72
|
+
await build(pkg)
|
|
73
|
+
for (;;) {
|
|
74
|
+
await healthy(fixSuite)
|
|
75
|
+
if (await specReview(pkg)) break
|
|
76
|
+
await fixSpec(pkg)
|
|
77
|
+
}
|
|
78
|
+
// The spent technical plan goes with the first package built from it.
|
|
79
|
+
await run("closing", removeScript([pkg, SPEC_FEEDBACK, ".gtd/SATISFIED.md", ARCHITECTURE]), {
|
|
80
|
+
label: "Closing out the package",
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The first queued package — the one a package step works on. */
|
|
85
|
+
export const nextPackage = (): string | undefined => [...glob(".gtd/packages/*.md")].sort()[0]
|
|
86
|
+
|
|
87
|
+
/** Build every package file under `.gtd/packages/`, in name order. */
|
|
88
|
+
export const packages = (): Promise<void> =>
|
|
89
|
+
scope("packages", async () => {
|
|
90
|
+
for (let pkg = nextPackage(); pkg !== undefined; pkg = nextPackage()) {
|
|
91
|
+
await scope("item", () => packageItem(pkg))
|
|
92
|
+
}
|
|
93
|
+
})
|