@pmelab/gtd 52.1.0 → 53.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/.claude-plugin/plugin.json +1 -1
- package/README.md +91 -129
- package/claude/hooks/drive.ts +0 -3
- package/claude/hooks/register.tsx +3 -68
- package/claude/hooks/ship.ts +85 -1595
- package/dist/chunks/{reserved-DxB5CROj.mjs → reserved-CQ7amcKG.mjs} +45 -132
- package/dist/chunks/{review-CKMSdvgW.mjs → review-CNgN_V8T.mjs} +2 -2
- package/dist/chunks/{tail-QZc7Uz2U.mjs → tail-JZ9PAOwp.mjs} +2 -14
- package/dist/chunks/{wire-BJuZWR3C.mjs → wire-C12dahrH.mjs} +2 -130
- package/dist/flows.mjs +1 -16
- package/dist/gtd.bundle.mjs +476 -982
- package/dist/testkit.mjs +58 -417
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +112 -363
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +311 -674
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +1 -86
- package/src/cli/Cli.ts +13 -133
- package/src/cli/Metrics.test.ts +21 -31
- package/src/cli/Metrics.ts +25 -36
- package/src/cli/Program.test.ts +1 -35
- package/src/cli/Program.ts +5 -64
- package/src/cli/ToolCommands.ts +0 -3
- package/src/edge/Edge.test.ts +20 -322
- package/src/edge/Edge.ts +10 -181
- package/src/edge/index.ts +0 -1
- package/src/flows/runtime.test.ts +0 -27
- package/src/flows/runtime.ts +1 -49
- package/src/install/Install.ts +15 -24
- package/src/replay/Replay.test.ts +0 -169
- package/src/replay/Replay.ts +4 -93
- package/src/replay/index.ts +0 -1
- package/src/testkit/Layers.ts +0 -1
- package/src/wire/BeatDocument.ts +2 -12
- package/src/wire/BeatStatus.ts +0 -3
- package/src/wire/WireSchema.test.ts +0 -18
- package/src/wire/index.ts +0 -2
- package/src/wire/types.ts +0 -7
- package/src/workflow/Workflow.ts +1 -9
- package/src/workflows/feature/criteria.test.ts +1 -14
- package/src/workflows/feature/criteria.ts +6 -38
- package/src/workflows/feature/feature.ts +34 -118
- package/src/workflows/feature/hands-on-skip.feature +2 -70
- package/src/workflows/feature/index.ts +0 -1
- package/src/workflows/feature/one-step-land.feature +0 -4
- package/src/workflows/feature/prototype-rounds.feature +1 -1
- package/src/workflows/feature/prototype.feature +3 -3
- package/src/workflows/feature/prototype.ts +18 -29
- package/src/workflows/feature/refactor-absorbs.feature +3 -8
- package/src/workflows/feature/refactor-first.feature +0 -5
- package/src/workflows/feature/refactor-rebuild.feature +0 -6
- package/src/workflows/feature/refactor-review.feature +11 -11
- package/src/workflows/feature/refactor.ts +30 -64
- package/src/workflows/feature/review-gate.feature +1 -1
- package/src/workflows/feature/seam-rejection.feature +0 -4
- package/src/workflows/feature/seam.test.ts +7 -26
- package/src/workflows/feature/seam.ts +52 -165
- package/src/workflows/index.ts +0 -1
- package/src/workflows/shared/lenses.ts +0 -13
- package/src/workflows/shared/options.test.ts +2 -11
- package/src/workflows/shared/options.ts +4 -21
- package/src/workflows/shared/review.test.ts +3 -3
- package/src/workflows/shared/steps.ts +0 -2
- package/src/workflows/shared/text.test.ts +10 -80
- package/src/workflows/shared/text.ts +35 -211
- package/src/workflows/shared/vars.ts +2 -8
- package/src/workflows/shared/workflows.steps.ts +0 -13
- package/claude/hooks/context.ts +0 -66
- package/src/replay/Slices.test.ts +0 -73
- package/src/replay/Slices.ts +0 -45
- package/src/wire/ContextLimit.fixture.ts +0 -30
- package/src/wire/ContextLimit.test.ts +0 -96
- package/src/wire/ContextLimit.ts +0 -169
- package/src/wire/Slices.ts +0 -25
- package/src/wire/golden/1/slices.json +0 -11
- package/src/workflows/feature/context-limit.feature +0 -37
- package/src/workflows/feature/continuation-cap.feature +0 -69
- package/src/workflows/feature/continuation-rebuild.feature +0 -92
- package/src/workflows/feature/continuation-slices.feature +0 -68
- package/src/workflows/feature/continuation.feature +0 -209
- package/src/workflows/feature/slice-findings.feature +0 -256
- package/src/workflows/feature/slicing-cap.feature +0 -23
- package/src/workflows/feature/slicing.feature +0 -297
- package/src/workflows/feature/slicing.test.ts +0 -49
- package/src/workflows/feature/slicing.ts +0 -105
- package/src/workflows/shared/continuation.ts +0 -92
package/src/edge/index.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
kept,
|
|
8
8
|
mark,
|
|
9
9
|
protocolMismatch,
|
|
10
|
-
slice,
|
|
11
10
|
type FlowContext,
|
|
12
11
|
} from "./runtime.js"
|
|
13
12
|
|
|
@@ -128,29 +127,3 @@ describe("kept()", () => {
|
|
|
128
127
|
expect(FLOWS_PROTOCOL).toBe(1)
|
|
129
128
|
})
|
|
130
129
|
})
|
|
131
|
-
|
|
132
|
-
describe("slice()", () => {
|
|
133
|
-
const withSlices = () => {
|
|
134
|
-
const calls: string[] = []
|
|
135
|
-
installContext({
|
|
136
|
-
enterSlice: (n: string) => calls.push(`enter ${n}`),
|
|
137
|
-
leaveSlice: () => calls.push("leave"),
|
|
138
|
-
} as unknown as FlowContext)
|
|
139
|
-
return calls
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
it("brackets fn with enter and leave and resolves to its value", async () => {
|
|
143
|
-
const calls = withSlices()
|
|
144
|
-
const value = await slice("refactor", async () => {
|
|
145
|
-
calls.push("fn")
|
|
146
|
-
return 42
|
|
147
|
-
})
|
|
148
|
-
expect(value).toBe(42)
|
|
149
|
-
expect(calls).toEqual(["enter refactor", "fn", "leave"])
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
it("runs fn unsliced on a context without enterSlice", async () => {
|
|
153
|
-
installContext(context)
|
|
154
|
-
expect(await slice("refactor", async () => "ran")).toBe("ran")
|
|
155
|
-
})
|
|
156
|
-
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -16,18 +16,9 @@ export interface SteeringOptions {
|
|
|
16
16
|
readonly base?: string | undefined
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Shares of the model's context window at which a driver stops the turn — `gtd next --json`'s `context`. */
|
|
20
|
-
export interface ContextLimitOptions {
|
|
21
|
-
/** Ask the agent to wrap up with a handoff note. Driver default 0.6. */
|
|
22
|
-
readonly soft?: number | undefined
|
|
23
|
-
/** Stop the turn outright. Driver default 0.7. */
|
|
24
|
-
readonly hard?: number | undefined
|
|
25
|
-
}
|
|
26
|
-
|
|
27
19
|
export interface AgentOptions extends SteeringOptions {
|
|
28
20
|
readonly model?: string | undefined
|
|
29
21
|
readonly system?: string | undefined
|
|
30
|
-
readonly context?: ContextLimitOptions | undefined
|
|
31
22
|
/**
|
|
32
23
|
* A turn that changes nothing completes the step. Without this an empty turn
|
|
33
24
|
* is an attempt: recorded, but the process stays at the step (a stall).
|
|
@@ -124,8 +115,6 @@ export interface ScopeOptions {
|
|
|
124
115
|
readonly model?: string | undefined
|
|
125
116
|
/** The system prompt every agent step inside runs with, unless it sets its own. */
|
|
126
117
|
readonly system?: string | undefined
|
|
127
|
-
/** The context limit every agent step inside runs with; a nested scope's or a step's own replaces it key by key. */
|
|
128
|
-
readonly context?: ContextLimitOptions | undefined
|
|
129
118
|
/** The skill names every agent step inside declares — `gtd next --json`'s `skills`. A nested scope inherits it unless it sets its own. */
|
|
130
119
|
readonly skills?: readonly string[] | undefined
|
|
131
120
|
/** The file access every agent step inside runs with — `gtd next --json`'s `access`. A nested scope inherits it; its own replaces it wholesale. */
|
|
@@ -161,9 +150,6 @@ export interface FlowContext {
|
|
|
161
150
|
readonly refuse: (message: string) => never
|
|
162
151
|
readonly pushScope: (scope: ScopeOptions) => void
|
|
163
152
|
readonly popScope: () => void
|
|
164
|
-
/** Absent on an engine older than `slice`; FLOWS_PROTOCOL stays 1. */
|
|
165
|
-
readonly enterSlice?: (name: string) => void
|
|
166
|
-
readonly leaveSlice?: () => void
|
|
167
153
|
readonly read: (path: string) => string | undefined
|
|
168
154
|
readonly glob: (pattern: string) => readonly string[]
|
|
169
155
|
/** The last step's changes, content read on demand. */
|
|
@@ -298,21 +284,6 @@ export const scope = async <T>(scope: string | ScopeOptions, fn: () => Promise<T
|
|
|
298
284
|
}
|
|
299
285
|
}
|
|
300
286
|
|
|
301
|
-
/**
|
|
302
|
-
* Mark the span `fn` covers as one named slice of the process. Lands no
|
|
303
|
-
* commit, prefixes no step name, opens no memory scope.
|
|
304
|
-
*/
|
|
305
|
-
export const slice = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
|
|
306
|
-
const context = ctx()
|
|
307
|
-
if (typeof context.enterSlice !== "function") return fn()
|
|
308
|
-
context.enterSlice(name)
|
|
309
|
-
try {
|
|
310
|
-
return await fn()
|
|
311
|
-
} finally {
|
|
312
|
-
context.leaveSlice?.()
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
287
|
// ── Reads of the commit replay stands on ────────────────────────────────────
|
|
317
288
|
|
|
318
289
|
export const read = (path: string): string | undefined => ctx().read(path)
|
|
@@ -501,36 +472,17 @@ export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().op
|
|
|
501
472
|
/** A workflow's one flow. */
|
|
502
473
|
export type Flow = () => Promise<void>
|
|
503
474
|
|
|
504
|
-
/** The slice `gtd summary --slice` writes the closing message for. */
|
|
505
|
-
export interface SummarySlice {
|
|
506
|
-
readonly name: string
|
|
507
|
-
/** Exclusive, as in git's `base..tip`. */
|
|
508
|
-
readonly base: string
|
|
509
|
-
readonly tip: string
|
|
510
|
-
/** 1-based stack position: `index === count` is the last slice. */
|
|
511
|
-
readonly index: number
|
|
512
|
-
readonly count: number
|
|
513
|
-
}
|
|
514
|
-
|
|
515
475
|
export interface SummaryContext {
|
|
516
|
-
/** Whole process, even under a slice. */
|
|
517
476
|
readonly entryCommit: string
|
|
518
|
-
/** The slice's `base` under `--slice`. */
|
|
519
477
|
readonly processBase: string
|
|
520
|
-
/** The slice's `tip` under `--slice`. */
|
|
521
478
|
readonly processTip: string
|
|
522
|
-
/** The slice's own under `--slice`. */
|
|
523
479
|
readonly humanCommits: readonly { readonly hash: string; readonly state: string }[]
|
|
524
|
-
/** The slice's own under `--slice`. */
|
|
525
480
|
readonly processCost: number
|
|
526
|
-
/** The slice's own under `--slice`. */
|
|
527
481
|
readonly processCostByModel: readonly { readonly model: string; readonly cost: number }[]
|
|
528
|
-
/** Every `Gtd-Mark` of the process's commits, oldest first
|
|
482
|
+
/** Every `Gtd-Mark` of the process's commits, oldest first. */
|
|
529
483
|
readonly marks: readonly Mark[]
|
|
530
484
|
readonly vars: Readonly<Record<string, string>>
|
|
531
485
|
readonly env: Readonly<Record<string, string>>
|
|
532
|
-
/** Present only under `gtd summary --slice`; the key is absent, not undefined, otherwise. */
|
|
533
|
-
readonly slice?: SummarySlice
|
|
534
486
|
}
|
|
535
487
|
|
|
536
488
|
/** `gtd summary`'s prompt — a workflow module's optional `summary` export. */
|
package/src/install/Install.ts
CHANGED
|
@@ -81,10 +81,7 @@ while :; do
|
|
|
81
81
|
model="$(gtd next --json=model)"
|
|
82
82
|
system="$(gtd next --json=system)"
|
|
83
83
|
validate="$(gtd next --json=validate)"
|
|
84
|
-
context="$(gtd next --json=context)"
|
|
85
84
|
log="$(gtd next --json=log)"
|
|
86
|
-
# Absolute, since the hook runs wherever the agent last cd'ed to.
|
|
87
|
-
case "$log" in /*) ;; *) log="$(git rev-parse --show-toplevel)/$log" ;; esac
|
|
88
85
|
# \`model\` is a hint, not a model name — the bundled workflow ships
|
|
89
86
|
# \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
|
|
90
87
|
# from your environment; unset, the hint is dropped and the agent runs
|
|
@@ -93,18 +90,6 @@ while :; do
|
|
|
93
90
|
smart) model="\${GTD_LOOP_MODEL_SMART:-}" ;;
|
|
94
91
|
base) model="\${GTD_LOOP_MODEL_BASE:-}" ;;
|
|
95
92
|
esac
|
|
96
|
-
# The step's context limit, as a Claude Code hook after every tool
|
|
97
|
-
# call and before compacting. Past \`hard\` it stops the turn and
|
|
98
|
-
# appends why to the stop file, so we land nothing and stop too.
|
|
99
|
-
soft="$(printf '%s' "$context" | sed -n 's/.*"soft":\\([0-9.]*\\).*/\\1/p')"
|
|
100
|
-
hard="$(printf '%s' "$context" | sed -n 's/.*"hard":\\([0-9.]*\\).*/\\1/p')"
|
|
101
|
-
window="\${GTD_LOOP_CONTEXT_WINDOW:-}"
|
|
102
|
-
case "$model" in *'[1m]'* | *'[1M]'*) window="\${window:-1000000}" ;; esac
|
|
103
|
-
hook="gtd context-hook\${soft:+ --soft $soft}\${hard:+ --hard $hard}\${window:+ --window $window}"
|
|
104
|
-
hooks="[{\\"hooks\\":[{\\"type\\":\\"command\\",\\"command\\":\\"$hook\\"}]}]"
|
|
105
|
-
settings="{\\"hooks\\":{\\"PostToolUse\\":$hooks,\\"PreCompact\\":$hooks}}"
|
|
106
|
-
stop="$log.context-stop"
|
|
107
|
-
rm -f "$stop"
|
|
108
93
|
# One turn, its prompt on stdin. The prompt embeds a full diff, so it
|
|
109
94
|
# never goes on argv — argv is capped (~1 MB on macOS, and POSIX
|
|
110
95
|
# guarantees only 4 KB, ARG_MAX), and a diff crosses that far sooner
|
|
@@ -112,15 +97,15 @@ while :; do
|
|
|
112
97
|
# whether \`claude --resume\` re-applies the original session's is a
|
|
113
98
|
# harness detail gtd cannot verify from outside. Its JSON envelope
|
|
114
99
|
# goes to the log, and its \`total_cost_usd\` adds up into $cost. An
|
|
115
|
-
# \`is_error\` envelope (max turns, an error mid-turn) or one
|
|
116
|
-
#
|
|
117
|
-
#
|
|
100
|
+
# \`is_error\` envelope (max turns, an error mid-turn) or one a hook
|
|
101
|
+
# stopped fails the turn even at exit 0: landing it would land the
|
|
102
|
+
# failure as work. Only a turn that never ran (no envelope: the
|
|
118
103
|
# session flag was refused) is worth retrying under the other flag.
|
|
119
104
|
turn() {
|
|
120
|
-
out="$(
|
|
105
|
+
out="$(claude -p "$@" \\
|
|
121
106
|
\${model:+--model "$model"} \\
|
|
122
107
|
\${system:+--system-prompt "$system"} \\
|
|
123
|
-
--
|
|
108
|
+
--output-format json \\
|
|
124
109
|
--dangerously-skip-permissions 2>>"$log")" && ok=0 || ok=$?
|
|
125
110
|
printf '%s\\n' "$out" >>"$log"
|
|
126
111
|
case "$out" in *'"type":"result"'*) ran=true ;; *) ran=false ;; esac
|
|
@@ -130,10 +115,6 @@ while :; do
|
|
|
130
115
|
usd="$(printf '%s\\n' "$out" | sed -n 's/.*"total_cost_usd":\\([0-9.eE+-]*\\).*/\\1/p' | tail -n 1)"
|
|
131
116
|
[ -z "$usd" ] || cost="$(awk -v a="\${cost:-0}" -v b="$usd" 'BEGIN { printf "%.6f", a + b }')"
|
|
132
117
|
[ "\${cost:-}" != 0.000000 ] || cost=
|
|
133
|
-
if [ -s "$stop" ]; then
|
|
134
|
-
cat "$stop" >&2
|
|
135
|
-
exit 1
|
|
136
|
-
fi
|
|
137
118
|
return "$ok"
|
|
138
119
|
}
|
|
139
120
|
# Plain \`gtd next\` (not --json=content) renders the diff once.
|
|
@@ -619,6 +600,16 @@ way — an existing \`gtd-loop\` survives untouched beside the new
|
|
|
619
600
|
\`gtd-door review [base]\`). When either is still present beside the suite,
|
|
620
601
|
name it to the user as superseded and ask before removing it — never remove
|
|
621
602
|
one unasked.
|
|
603
|
+
|
|
604
|
+
An older \`gtd-build\` passed a \`gtd context-hook\` hook to every turn, and
|
|
605
|
+
an older setup guide had it registered in Claude Code's own settings
|
|
606
|
+
(\`PostToolUse\`/\`PreCompact\`). That command no longer exists: a hook
|
|
607
|
+
still calling it fails on every tool call. Replace such a \`gtd-build\` as a
|
|
608
|
+
differing file above, and look for a \`gtd context-hook\` entry in the user's
|
|
609
|
+
Claude Code settings files (\`~/.claude/settings.json\`, the repository's
|
|
610
|
+
\`.claude/settings.json\` and \`.claude/settings.local.json\`): name each file
|
|
611
|
+
to the user, then remove that entry alone, leaving every other key
|
|
612
|
+
byte-identical.
|
|
622
613
|
`
|
|
623
614
|
|
|
624
615
|
const EDITOR_INTEGRATION = `
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
restart,
|
|
16
16
|
run,
|
|
17
17
|
scope,
|
|
18
|
-
slice,
|
|
19
18
|
threads,
|
|
20
19
|
type Flow,
|
|
21
20
|
type ScopeAccess,
|
|
@@ -1207,171 +1206,3 @@ describe("human({ approve })", () => {
|
|
|
1207
1206
|
expect(outcome.kind).toBe("rest")
|
|
1208
1207
|
})
|
|
1209
1208
|
})
|
|
1210
|
-
|
|
1211
|
-
describe("slice()", () => {
|
|
1212
|
-
const spansOf = (o: ReplayOutcome) => (o as { slices: unknown }).slices
|
|
1213
|
-
|
|
1214
|
-
it("records enteredAt and leftAt as the commit replay stood on", async () => {
|
|
1215
|
-
const wf = async () => {
|
|
1216
|
-
await human("a")
|
|
1217
|
-
await slice("refactor", async () => {
|
|
1218
|
-
await human("b")
|
|
1219
|
-
})
|
|
1220
|
-
await human("c")
|
|
1221
|
-
}
|
|
1222
|
-
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "c")
|
|
1223
|
-
const outcome = await replayOf(wf, h)
|
|
1224
|
-
expect(outcome).toMatchObject({ kind: "rest" })
|
|
1225
|
-
expect(spansOf(outcome)).toEqual([
|
|
1226
|
-
{ name: "refactor", enteredAt: hashOf(1), leftAt: hashOf(2) },
|
|
1227
|
-
])
|
|
1228
|
-
})
|
|
1229
|
-
|
|
1230
|
-
it("an ended outcome carries every span of the episode that just ended", async () => {
|
|
1231
|
-
const wf = async () => {
|
|
1232
|
-
await slice("one", async () => {
|
|
1233
|
-
await human("a")
|
|
1234
|
-
})
|
|
1235
|
-
await slice("two", async () => {
|
|
1236
|
-
await human("b")
|
|
1237
|
-
})
|
|
1238
|
-
}
|
|
1239
|
-
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
|
|
1240
|
-
const outcome = await replayOf(wf, h)
|
|
1241
|
-
expect(outcome).toMatchObject({ kind: "ended", via: "return" })
|
|
1242
|
-
expect(spansOf(outcome)).toEqual([
|
|
1243
|
-
{ name: "one", enteredAt: hashOf(0), leftAt: hashOf(1) },
|
|
1244
|
-
{ name: "two", enteredAt: hashOf(1), leftAt: hashOf(2) },
|
|
1245
|
-
])
|
|
1246
|
-
})
|
|
1247
|
-
|
|
1248
|
-
it("restart() inside a slice ends via restart with that span's leftAt undefined", async () => {
|
|
1249
|
-
const wf = async () => {
|
|
1250
|
-
await human("a")
|
|
1251
|
-
await slice("risky", async () => {
|
|
1252
|
-
await human("b")
|
|
1253
|
-
await restart()
|
|
1254
|
-
})
|
|
1255
|
-
}
|
|
1256
|
-
const h = new History().land("human", "a", 1, "b").land("human", "b", 1, "done")
|
|
1257
|
-
const outcome = await replayOf(wf, h)
|
|
1258
|
-
expect(outcome).toMatchObject({ kind: "ended", via: "restart" })
|
|
1259
|
-
expect(spansOf(outcome)).toEqual([{ name: "risky", enteredAt: hashOf(1), leftAt: undefined }])
|
|
1260
|
-
})
|
|
1261
|
-
|
|
1262
|
-
it("keeps leftAt undefined when the process rests inside the slice", async () => {
|
|
1263
|
-
const wf = async () => {
|
|
1264
|
-
await slice("refactor", async () => {
|
|
1265
|
-
await human("a")
|
|
1266
|
-
})
|
|
1267
|
-
}
|
|
1268
|
-
const outcome = await replayOf(wf, new History())
|
|
1269
|
-
expect(spansOf(outcome)).toEqual([
|
|
1270
|
-
{ name: "refactor", enteredAt: hashOf(0), leftAt: undefined },
|
|
1271
|
-
])
|
|
1272
|
-
})
|
|
1273
|
-
|
|
1274
|
-
it("records enteredAt === leftAt for a slice that landed no commit", async () => {
|
|
1275
|
-
const wf = async () => {
|
|
1276
|
-
await human("a")
|
|
1277
|
-
await slice("empty", async () => undefined)
|
|
1278
|
-
await human("b")
|
|
1279
|
-
}
|
|
1280
|
-
const h = new History().land("human", "a", 1, "b")
|
|
1281
|
-
expect(spansOf(await replayOf(wf, h))).toEqual([
|
|
1282
|
-
{ name: "empty", enteredAt: hashOf(1), leftAt: hashOf(1) },
|
|
1283
|
-
])
|
|
1284
|
-
})
|
|
1285
|
-
|
|
1286
|
-
it("prefixes no step name and ignores an enclosing scope", async () => {
|
|
1287
|
-
const wf = async () => {
|
|
1288
|
-
await scope("build", async () => {
|
|
1289
|
-
await slice("refactor", async () => {
|
|
1290
|
-
await human("gate")
|
|
1291
|
-
})
|
|
1292
|
-
})
|
|
1293
|
-
}
|
|
1294
|
-
const outcome = await replayOf(wf, new History())
|
|
1295
|
-
expect(outcome).toMatchObject({ kind: "rest", rest: { name: "build.gate" } })
|
|
1296
|
-
expect((spansOf(outcome) as { name: string }[])[0]!.name).toBe("refactor")
|
|
1297
|
-
})
|
|
1298
|
-
|
|
1299
|
-
const failed = async (wf: Flow) => {
|
|
1300
|
-
const outcome = await replayOf(wf, new History())
|
|
1301
|
-
expect(outcome.kind).toBe("failed")
|
|
1302
|
-
return (outcome as { message: string }).message
|
|
1303
|
-
}
|
|
1304
|
-
|
|
1305
|
-
it.each(["Refactor", "step_1", "v2.api", ""])("refuses the name %j", async (name) => {
|
|
1306
|
-
expect(await failed(async () => slice(name, async () => human("a")))).toBe(
|
|
1307
|
-
`gtd: slice "${name}": name must match /^[a-z][a-z0-9-]*$/`,
|
|
1308
|
-
)
|
|
1309
|
-
})
|
|
1310
|
-
|
|
1311
|
-
it("accepts refactor-2", async () => {
|
|
1312
|
-
const outcome = await replayOf(
|
|
1313
|
-
async () => slice("refactor-2", async () => human("a")),
|
|
1314
|
-
new History(),
|
|
1315
|
-
)
|
|
1316
|
-
expect(outcome.kind).toBe("rest")
|
|
1317
|
-
})
|
|
1318
|
-
|
|
1319
|
-
it("refuses the reserved name main", async () => {
|
|
1320
|
-
expect(await failed(async () => slice("main", async () => human("a")))).toBe(
|
|
1321
|
-
'gtd: slice "main" is reserved for the implicit slice',
|
|
1322
|
-
)
|
|
1323
|
-
})
|
|
1324
|
-
|
|
1325
|
-
it("refuses nesting, also through scope()", async () => {
|
|
1326
|
-
const message = 'gtd: slice "inner" is entered inside slice "outer" — slices do not nest'
|
|
1327
|
-
expect(
|
|
1328
|
-
await failed(async () => slice("outer", async () => slice("inner", async () => human("a")))),
|
|
1329
|
-
).toBe(message)
|
|
1330
|
-
expect(
|
|
1331
|
-
await failed(async () =>
|
|
1332
|
-
slice("outer", async () => scope("s", async () => slice("inner", async () => human("a")))),
|
|
1333
|
-
),
|
|
1334
|
-
).toBe(message)
|
|
1335
|
-
})
|
|
1336
|
-
|
|
1337
|
-
it("checks the name rule before main and nesting", async () => {
|
|
1338
|
-
expect(
|
|
1339
|
-
await failed(async () => slice("outer", async () => slice("Main", async () => human("a")))),
|
|
1340
|
-
).toBe('gtd: slice "Main": name must match /^[a-z][a-z0-9-]*$/')
|
|
1341
|
-
})
|
|
1342
|
-
|
|
1343
|
-
it("lets a flow that caught an error from a slice enter another", async () => {
|
|
1344
|
-
const wf = async () => {
|
|
1345
|
-
try {
|
|
1346
|
-
await slice("a", async () => {
|
|
1347
|
-
throw new Error("boom")
|
|
1348
|
-
})
|
|
1349
|
-
} catch {}
|
|
1350
|
-
await slice("b", async () => human("x"))
|
|
1351
|
-
}
|
|
1352
|
-
expect(await replayOf(wf, new History())).toMatchObject({ kind: "rest" })
|
|
1353
|
-
})
|
|
1354
|
-
|
|
1355
|
-
it("refuses a non-string name", async () => {
|
|
1356
|
-
expect(
|
|
1357
|
-
await failed(async () => slice(undefined as unknown as string, async () => human("a"))),
|
|
1358
|
-
).toContain("name must match")
|
|
1359
|
-
})
|
|
1360
|
-
|
|
1361
|
-
it("refuses a name entered twice, sequentially and from a loop", async () => {
|
|
1362
|
-
const message =
|
|
1363
|
-
'gtd: slice "again" is entered twice in this episode — a loop must sit inside its slice'
|
|
1364
|
-
expect(
|
|
1365
|
-
await failed(async () => {
|
|
1366
|
-
await slice("again", async () => undefined)
|
|
1367
|
-
await slice("again", async () => human("a"))
|
|
1368
|
-
}),
|
|
1369
|
-
).toBe(message)
|
|
1370
|
-
expect(
|
|
1371
|
-
await failed(async () => {
|
|
1372
|
-
for (let i = 0; i < 2; i++) await slice("again", async () => undefined)
|
|
1373
|
-
await human("a")
|
|
1374
|
-
}),
|
|
1375
|
-
).toBe(message)
|
|
1376
|
-
})
|
|
1377
|
-
})
|
package/src/replay/Replay.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
type ScopeAccess,
|
|
9
9
|
type AccessDef,
|
|
10
10
|
type ScopeOptions,
|
|
11
|
-
type ContextLimitOptions,
|
|
12
11
|
type StepRequest,
|
|
13
12
|
type Flow,
|
|
14
13
|
type Mark,
|
|
@@ -22,9 +21,8 @@ import {
|
|
|
22
21
|
unansweredQuestions,
|
|
23
22
|
} from "../steering/index.js"
|
|
24
23
|
import { accessShapeFault, foldAccess, sameAccess } from "./Access.js"
|
|
25
|
-
import {
|
|
24
|
+
import { globMatches } from "../wire/index.js"
|
|
26
25
|
import { refNameFault } from "./RefName.js"
|
|
27
|
-
import { IMPLICIT_SLICE, SLICE_NAME, type SliceSpan } from "./Slices.js"
|
|
28
26
|
import { diffTrees, isEmptyDiff, type TreeView } from "./Tree.js"
|
|
29
27
|
import {
|
|
30
28
|
formatStepId,
|
|
@@ -119,7 +117,6 @@ export type ReplayOutcome =
|
|
|
119
117
|
readonly landed: ReachedStep | undefined
|
|
120
118
|
/** Marks the flow made after the pending turn's step resolved; [] without a pending turn. */
|
|
121
119
|
readonly marks: readonly Mark[]
|
|
122
|
-
readonly slices: readonly SliceSpan[]
|
|
123
120
|
}
|
|
124
121
|
| {
|
|
125
122
|
readonly kind: "ended"
|
|
@@ -127,8 +124,6 @@ export type ReplayOutcome =
|
|
|
127
124
|
readonly trace: readonly ReachedStep[]
|
|
128
125
|
readonly landed: ReachedStep | undefined
|
|
129
126
|
readonly marks: readonly Mark[]
|
|
130
|
-
/** Spans of the episode that just ended; a restart() inside a slice leaves its leftAt undefined. */
|
|
131
|
-
readonly slices: readonly SliceSpan[]
|
|
132
127
|
}
|
|
133
128
|
| { readonly kind: "divergence"; readonly message: string }
|
|
134
129
|
| { readonly kind: "refused"; readonly message: string }
|
|
@@ -238,28 +233,6 @@ const budgeted = (
|
|
|
238
233
|
return { evidence: bounded, truncated }
|
|
239
234
|
}
|
|
240
235
|
|
|
241
|
-
// The innermost of `chain`, named by its full path.
|
|
242
|
-
const scopeLabel = (chain: readonly ScopeOptions[]): string => {
|
|
243
|
-
const path = chain.flatMap((o) => (o.name === undefined ? [] : [o.name])).join(".")
|
|
244
|
-
if (chain.at(-1)?.name !== undefined) return `scope "${path}"`
|
|
245
|
-
return path === "" ? "an unnamed scope" : `an unnamed scope inside "${path}"`
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Key by key, innermost last: a scope or step that sets only `hard` keeps the outer `soft`.
|
|
249
|
-
const inheritedContext = (
|
|
250
|
-
layers: readonly (ContextLimitOptions | undefined)[],
|
|
251
|
-
): ContextLimitOptions | undefined =>
|
|
252
|
-
layers.reduce<ContextLimitOptions | undefined>(
|
|
253
|
-
(merged, layer) =>
|
|
254
|
-
layer === undefined
|
|
255
|
-
? merged
|
|
256
|
-
: {
|
|
257
|
-
...merged,
|
|
258
|
-
...Object.fromEntries(Object.entries(layer).filter(([, value]) => value !== undefined)),
|
|
259
|
-
},
|
|
260
|
-
undefined,
|
|
261
|
-
)
|
|
262
|
-
|
|
263
236
|
interface Identity {
|
|
264
237
|
readonly model: string | undefined
|
|
265
238
|
readonly system: string | undefined
|
|
@@ -296,7 +269,7 @@ const STEERING_OPTIONS = ["file", "mode", "label", "base"]
|
|
|
296
269
|
// The option keys each step accepts. A gtd.config.ts is evaluated without a
|
|
297
270
|
// type check, so a misspelt key would otherwise be silently ignored.
|
|
298
271
|
const KNOWN_OPTIONS: Readonly<Record<StepKind, ReadonlySet<string>>> = {
|
|
299
|
-
agent: new Set([...STEERING_OPTIONS, "model", "system", "
|
|
272
|
+
agent: new Set([...STEERING_OPTIONS, "model", "system", "allowEmpty", "keep"]),
|
|
300
273
|
human: new Set([...STEERING_OPTIONS, "message", "acceptClean", "approve"]),
|
|
301
274
|
run: new Set(STEERING_OPTIONS),
|
|
302
275
|
judge: new Set([...STEERING_OPTIONS, "message"]),
|
|
@@ -354,7 +327,6 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
354
327
|
>()
|
|
355
328
|
let landed: ReachedStep | undefined
|
|
356
329
|
const marks: Mark[] = []
|
|
357
|
-
const slices: { name: string; enteredAt: string; leftAt: string | undefined }[] = []
|
|
358
330
|
let outcome: ReplayOutcome | undefined
|
|
359
331
|
let signal: (() => void) | undefined
|
|
360
332
|
const settled = new Promise<void>((resolve) => {
|
|
@@ -479,15 +451,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
479
451
|
if (s.model !== undefined) persona.model = s.model
|
|
480
452
|
if (s.system !== undefined) persona.system = s.system
|
|
481
453
|
}
|
|
482
|
-
|
|
483
|
-
return {
|
|
484
|
-
...request,
|
|
485
|
-
options: {
|
|
486
|
-
...persona,
|
|
487
|
-
...request.options,
|
|
488
|
-
...(context === undefined ? {} : { context }),
|
|
489
|
-
},
|
|
490
|
-
}
|
|
454
|
+
return { ...request, options: { ...persona, ...request.options } }
|
|
491
455
|
}
|
|
492
456
|
if (request.kind === "judge") {
|
|
493
457
|
const { evidence, truncated } = budgeted(request.evidence, input.budgetBytes)
|
|
@@ -626,34 +590,11 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
626
590
|
trace,
|
|
627
591
|
landed,
|
|
628
592
|
marks,
|
|
629
|
-
slices: slices.map((s) => ({ ...s })),
|
|
630
593
|
})
|
|
631
594
|
}
|
|
632
595
|
return landPending(step, input.pending)
|
|
633
596
|
}
|
|
634
597
|
|
|
635
|
-
// Each layer on its own first, so a bad scope or workflow option is named as
|
|
636
|
-
// such rather than blamed on the step it reached; then the merged limit.
|
|
637
|
-
const scopeContextFault = (): string | undefined => {
|
|
638
|
-
for (const [i, s] of scopes.entries()) {
|
|
639
|
-
const fault = s.context === undefined ? undefined : contextShapeFault(s.context)
|
|
640
|
-
if (fault !== undefined)
|
|
641
|
-
return `the context of ${scopeLabel(scopes.slice(0, i + 1))}: ${fault}`
|
|
642
|
-
}
|
|
643
|
-
return undefined
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
const resolvedContextFault = (
|
|
647
|
-
request: Extract<StepRequest, { kind: "agent" }>,
|
|
648
|
-
): string | undefined => {
|
|
649
|
-
const own = request.options.context
|
|
650
|
-
const layered = scopeContextFault() ?? (own === undefined ? undefined : contextShapeFault(own))
|
|
651
|
-
if (layered !== undefined) return layered
|
|
652
|
-
const resolved = resolve(request)
|
|
653
|
-
const context = resolved.kind === "agent" ? resolved.options.context : undefined
|
|
654
|
-
return context === undefined ? undefined : contextLimitFault(context)
|
|
655
|
-
}
|
|
656
|
-
|
|
657
598
|
// gtd.config.ts is loaded without a type check.
|
|
658
599
|
const requestFault = (request: Exclude<StepRequest, { kind: "restart" }>): string | undefined => {
|
|
659
600
|
if (request.kind === "run" && typeof request.body !== "string") {
|
|
@@ -661,8 +602,6 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
661
602
|
}
|
|
662
603
|
if (request.kind !== "agent") return undefined
|
|
663
604
|
const name = scoped(request.name)
|
|
664
|
-
const contextFault = resolvedContextFault(request)
|
|
665
|
-
if (contextFault !== undefined) return `gtd: step "${name}": ${contextFault}`
|
|
666
605
|
if (request.options.keep === true) {
|
|
667
606
|
// A clean allowEmpty keep would land an ordinary commit with no Gtd-Kept: a silent skip.
|
|
668
607
|
if (request.options.allowEmpty === true) {
|
|
@@ -696,7 +635,7 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
696
635
|
kind: "divergence",
|
|
697
636
|
message: `gtd: the flow ended, but commit ${short(commits[cursor]!.hash)} continues the episode — ${DIVERGED}`,
|
|
698
637
|
}
|
|
699
|
-
: { kind: "ended", via, trace, landed, marks
|
|
638
|
+
: { kind: "ended", via, trace, landed, marks }
|
|
700
639
|
|
|
701
640
|
const changesSince = (hash: string): readonly Change[] => {
|
|
702
641
|
const tree = treeAt(hash)
|
|
@@ -723,34 +662,6 @@ export const replay = async (input: ReplayInput): Promise<ReplayOutcome> => {
|
|
|
723
662
|
scopes.pop()
|
|
724
663
|
scopeCallIds.pop()
|
|
725
664
|
},
|
|
726
|
-
enterSlice: (name) => {
|
|
727
|
-
if (outcome !== undefined) return
|
|
728
|
-
const fail = (message: string): never => {
|
|
729
|
-
void finish({ kind: "failed", message })
|
|
730
|
-
throw new Stop()
|
|
731
|
-
}
|
|
732
|
-
if (typeof name !== "string" || !SLICE_NAME.test(name)) {
|
|
733
|
-
fail(`gtd: slice "${name}": name must match ${SLICE_NAME}`)
|
|
734
|
-
}
|
|
735
|
-
if (name === IMPLICIT_SLICE) {
|
|
736
|
-
fail(`gtd: slice "${IMPLICIT_SLICE}" is reserved for the implicit slice`)
|
|
737
|
-
}
|
|
738
|
-
const open = slices.find((s) => s.leftAt === undefined)
|
|
739
|
-
if (open !== undefined) {
|
|
740
|
-
fail(`gtd: slice "${name}" is entered inside slice "${open.name}" — slices do not nest`)
|
|
741
|
-
}
|
|
742
|
-
if (slices.some((s) => s.name === name)) {
|
|
743
|
-
fail(
|
|
744
|
-
`gtd: slice "${name}" is entered twice in this episode — a loop must sit inside its slice`,
|
|
745
|
-
)
|
|
746
|
-
}
|
|
747
|
-
slices.push({ name, enteredAt: position.hash, leftAt: undefined })
|
|
748
|
-
},
|
|
749
|
-
leaveSlice: () => {
|
|
750
|
-
if (outcome !== undefined) return
|
|
751
|
-
const open = slices.find((s) => s.leftAt === undefined)
|
|
752
|
-
if (open !== undefined) open.leftAt = position.hash
|
|
753
|
-
},
|
|
754
665
|
read: (path) => position.tree.read(path),
|
|
755
666
|
glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
|
|
756
667
|
changes: () => changesBetween(previousPosition.tree, position.tree),
|
package/src/replay/index.ts
CHANGED
package/src/testkit/Layers.ts
CHANGED
|
@@ -205,7 +205,6 @@ const missingTool = (name: string) => () =>
|
|
|
205
205
|
const missingToolCommands: ToolCommands["Type"] = {
|
|
206
206
|
ui: missingTool("ui"),
|
|
207
207
|
lsp: missingTool("lsp"),
|
|
208
|
-
contextHook: missingTool("context-hook"),
|
|
209
208
|
judge: () =>
|
|
210
209
|
Promise.reject(
|
|
211
210
|
new Error("gtd judge run: ToolCommands has no test double wired into testLayers()"),
|
package/src/wire/BeatDocument.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import type { BeatKind, Demand, DemandSession } from "./Demand.js"
|
|
2
2
|
import type { BeatStatus, NextMatch, StatusChange } from "./BeatStatus.js"
|
|
3
|
-
import type {
|
|
4
|
-
Actor,
|
|
5
|
-
ApprovalEntry,
|
|
6
|
-
ModelCost,
|
|
7
|
-
StateMode,
|
|
8
|
-
StateName,
|
|
9
|
-
StepAccess,
|
|
10
|
-
StepContextLimit,
|
|
11
|
-
} from "./types.js"
|
|
3
|
+
import type { Actor, ApprovalEntry, ModelCost, StateMode, StateName, StepAccess } from "./types.js"
|
|
12
4
|
import { WIRE_SCHEMA } from "./constants.js"
|
|
13
5
|
|
|
14
6
|
/** `gtd next --json`'s `next` key — `null` when landing would commit nothing. */
|
|
@@ -64,7 +56,6 @@ export interface BeatDocument {
|
|
|
64
56
|
readonly session: DemandSession | undefined
|
|
65
57
|
readonly model: string | undefined
|
|
66
58
|
readonly system: string | undefined
|
|
67
|
-
readonly context: StepContextLimit | undefined
|
|
68
59
|
readonly validate: string | undefined
|
|
69
60
|
readonly log: string
|
|
70
61
|
readonly state: StateName
|
|
@@ -85,7 +76,7 @@ export interface BeatDocument {
|
|
|
85
76
|
}
|
|
86
77
|
|
|
87
78
|
/**
|
|
88
|
-
* Flatten a `Demand` plus its `BeatStatus` into the single
|
|
79
|
+
* Flatten a `Demand` plus its `BeatStatus` into the single 25-key document
|
|
89
80
|
* `gtd next --json` emits — the ONLY place the two are joined. `cost`/
|
|
90
81
|
* `costByModel` are omitted together, exactly when no cost was recorded
|
|
91
82
|
* (`cost <= 0`).
|
|
@@ -102,7 +93,6 @@ export const beatDocument = (demand: Demand, status: BeatStatus): BeatDocument =
|
|
|
102
93
|
session,
|
|
103
94
|
model: status.model,
|
|
104
95
|
system: status.system,
|
|
105
|
-
context: status.context,
|
|
106
96
|
validate,
|
|
107
97
|
log: status.log,
|
|
108
98
|
state: status.state,
|
package/src/wire/BeatStatus.ts
CHANGED
|
@@ -5,7 +5,6 @@ import type {
|
|
|
5
5
|
RenderedDemandSource,
|
|
6
6
|
StateMode,
|
|
7
7
|
StateName,
|
|
8
|
-
StepContextLimit,
|
|
9
8
|
} from "./types.js"
|
|
10
9
|
|
|
11
10
|
/** One pending change — `gtd next --json`'s `changes` entries. */
|
|
@@ -33,7 +32,6 @@ export interface BeatStatus {
|
|
|
33
32
|
readonly initial: boolean
|
|
34
33
|
readonly model: string | undefined
|
|
35
34
|
readonly system: string | undefined
|
|
36
|
-
readonly context: StepContextLimit | undefined
|
|
37
35
|
readonly log: string
|
|
38
36
|
readonly state: StateName
|
|
39
37
|
readonly actor: Actor
|
|
@@ -76,7 +74,6 @@ export const statusOf = (input: {
|
|
|
76
74
|
initial,
|
|
77
75
|
model: rendered.model,
|
|
78
76
|
system: rendered.system !== undefined && rendered.system !== "" ? rendered.system : undefined,
|
|
79
|
-
context: rendered.context,
|
|
80
77
|
log,
|
|
81
78
|
state: rendered.state,
|
|
82
79
|
actor: rendered.actor,
|